UI redesign Phase 1: NavigationSplitView with collapsible sidebar

- Replace root VStack with NavigationSplitView (2-column, collapsible sidebar)
- Add SidebarView: new chat button, conversation search, list with swipe actions
- Slim HeaderView to text-only (provider + model + star); remove all icon rows
- Move status pills (Online, MCP, Synced) to footer right side
- Remove version number and shortcut hints from footer
- Add resizable InputBar with drag handle (persisted height) and globe/network.slash online toggle
- Fix Norwegian menu appearing on English systems (CFBundleLocalizations in Info.plist)
- Add View menu (Model Info, History, Stats, Credits, Online Mode toggle ⌘⇧O)
- Add ⌘L as alias for Search Conversations (muscle memory for /load users)
- Add Check for Updates to Help menu with download URL from Gitea API
- Add one-time Intel/Rosetta deprecation warning on first launch
- Swift 6: fix self.Self.isoString() call sites in DatabaseService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:18:48 +02:00
co-authored by Claude Sonnet 4.6
parent cd0ceeab41
commit 8451db1142
19 changed files with 702 additions and 363 deletions
+41 -27
View File
@@ -134,15 +134,29 @@ final class DatabaseService: Sendable {
nonisolated static let shared = DatabaseService()
private let dbQueue: DatabaseQueue
private let isoFormatter: ISO8601DateFormatter
// Command history limit - keep most recent 5000 entries
private static let maxHistoryEntries = 5000
private nonisolated static let maxHistoryEntries = 5000
// ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
dateSeparator: .dash,
dateTimeSeparator: .standard,
timeSeparator: .colon,
timeZoneSeparator: .colon,
includingFractionalSeconds: true,
timeZone: .gmt
)
private nonisolated static func isoString(from date: Date) -> String {
isoStyle.format(date)
}
private nonisolated static func isoDate(from string: String) -> Date? {
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
}
nonisolated private init() {
isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fileManager = FileManager.default
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
try! migrator.migrate(dbQueue)
}
private var migrator: DatabaseMigrator {
private nonisolated var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1") { db in
@@ -375,7 +389,7 @@ final class DatabaseService: Sendable {
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
let now = Date()
let nowString = isoFormatter.string(from: now)
let nowString = Self.isoString(from: now)
let convRecord = ConversationRecord(
id: id.uuidString,
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp),
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
/// Update an existing conversation in-place: rename it, replace all its messages.
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
let nowString = isoFormatter.string(from: Date())
let nowString = Self.isoString(from: Date())
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
guard msg.role != .system else { return nil }
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: isoFormatter.string(from: msg.timestamp),
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index,
modelId: msg.modelId
)
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
let messages = messageRecords.compactMap { record -> Message? in
guard let msgId = UUID(uuidString: record.id),
let role = MessageRole(rawValue: record.role),
let timestamp = self.isoFormatter.date(from: record.timestamp)
let timestamp = Self.isoDate(from: record.timestamp)
else { return nil }
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
@@ -484,8 +498,8 @@ final class DatabaseService: Sendable {
}
guard let convId = UUID(uuidString: convRecord.id),
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
let createdAt = Self.isoDate(from: convRecord.createdAt),
let updatedAt = Self.isoDate(from: convRecord.updatedAt)
else { return nil }
let conversation = Conversation(
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
return records.compactMap { record -> Conversation? in
guard let id = UUID(uuidString: record.id),
let createdAt = self.isoFormatter.date(from: record.createdAt),
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
let createdAt = Self.isoDate(from: record.createdAt),
let updatedAt = Self.isoDate(from: record.updatedAt)
else { return nil }
// Fetch message count without loading all messages
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
.order(Column("sortOrder").desc)
.fetchOne(db)
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
// Derive primary model: prefer the stored field, fall back to last message's modelId
let primaryModel = record.primaryModel ?? lastMsg?.modelId
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
convRecord.name = name
}
convRecord.updatedAt = self.isoFormatter.string(from: Date())
convRecord.updatedAt = Self.isoString(from: Date())
try convRecord.update(db)
if let messages = messages {
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
content: msg.content,
tokens: msg.tokens,
cost: msg.cost,
timestamp: self.isoFormatter.string(from: msg.timestamp),
timestamp: Self.isoString(from: msg.timestamp),
sortOrder: index
)
}
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
let record = HistoryRecord(
id: UUID().uuidString,
input: input,
timestamp: isoFormatter.string(from: now)
timestamp: Self.isoString(from: now)
)
try? dbQueue.write { db in
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else {
guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
guard let date = isoFormatter.date(from: record.timestamp) else {
guard let date = Self.isoDate(from: record.timestamp) else {
return nil
}
return (input: record.input, timestamp: date)
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
nonisolated func saveEmailLog(_ log: EmailLog) {
let record = EmailLogRecord(
id: log.id.uuidString,
timestamp: isoFormatter.string(from: log.timestamp),
timestamp: Self.isoString(from: log.timestamp),
sender: log.sender,
subject: log.subject,
emailContent: log.emailContent,
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
.fetchAll(db)
return records.compactMap { record in
guard let timestamp = isoFormatter.date(from: record.timestamp),
guard let timestamp = Self.isoDate(from: record.timestamp),
let status = EmailLogStatus(rawValue: record.status),
let id = UUID(uuidString: record.id) else {
return nil
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
// MARK: - Embedding Operations
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date())
let now = Self.isoString(from: Date())
let record = MessageEmbeddingRecord(
message_id: messageId.uuidString,
embedding: embedding,
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
}
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
let now = isoFormatter.string(from: Date())
let now = Self.isoString(from: Date())
let record = ConversationEmbeddingRecord(
conversation_id: conversationId.uuidString,
embedding: embedding,
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
return Array(results.prefix(limit))
}
private func deserializeEmbedding(_ data: Data) -> [Float] {
private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
var embedding: [Float] = []
embedding.reserveCapacity(data.count / 4)
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
model: String?,
tokenCount: Int?
) throws {
let now = isoFormatter.string(from: Date())
let now = Self.isoString(from: Date())
let record = ConversationSummaryRecord(
id: UUID().uuidString,
conversation_id: conversationId.uuidString,