From 8451db11421d166b5df6f423f9fbc3f5f815391d Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Tue, 16 Jun 2026 11:18:48 +0200 Subject: [PATCH] UI redesign Phase 1: NavigationSplitView with collapsible sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- oAI.xcodeproj/project.pbxproj | 12 +- .../xcshareddata/xcschemes/oAI.xcscheme | 2 +- oAI/Info.plist | 8 + oAI/Localizable.xcstrings | 128 +++++++++++ oAI/Services/DatabaseService.swift | 68 +++--- oAI/Services/EmbeddingService.swift | 4 +- oAI/Services/EncryptionService.swift | 6 +- oAI/Services/GitSyncService.swift | 2 +- oAI/Services/SettingsService.swift | 18 ++ oAI/Services/UpdateCheckService.swift | 30 +++ oAI/Utilities/Logging.swift | 38 +-- oAI/ViewModels/ChatViewModel.swift | 4 +- oAI/Views/Main/ChatView.swift | 15 +- oAI/Views/Main/ContentView.swift | 176 +++++--------- oAI/Views/Main/FooterView.swift | 37 +-- oAI/Views/Main/HeaderView.swift | 73 +----- oAI/Views/Main/InputBar.swift | 192 ++++++++-------- oAI/Views/Main/SidebarView.swift | 216 ++++++++++++++++++ oAI/oAIApp.swift | 36 +++ 19 files changed, 702 insertions(+), 363 deletions(-) create mode 100644 oAI/Views/Main/SidebarView.swift diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index 11fd0a9..a2ee833 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -279,11 +279,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.9; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -323,11 +323,11 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.3.9; + MACOSX_DEPLOYMENT_TARGET = 27.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme index 1ace073..2336f58 100644 --- a/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme +++ b/oAI.xcodeproj/xcshareddata/xcschemes/oAI.xcscheme @@ -34,7 +34,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - language = "nb" + language = "en" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/oAI/Info.plist b/oAI/Info.plist index eee344e..5d5967f 100644 --- a/oAI/Info.plist +++ b/oAI/Info.plist @@ -6,5 +6,13 @@ oAI.help CFBundleHelpBookName oAI Help + CFBundleLocalizations + + en + nb + da + de + sv + diff --git a/oAI/Localizable.xcstrings b/oAI/Localizable.xcstrings index b7fd5c3..46e7901 100644 --- a/oAI/Localizable.xcstrings +++ b/oAI/Localizable.xcstrings @@ -1,6 +1,13 @@ { "sourceLanguage" : "en", "strings" : { + "·" : { + "comment" : "A separator between the message count and the date.", + "isCommentAutoGenerated" : true + }, + "· %@" : { + + }, "(always used)" : { "localizations" : { "da" : { @@ -432,6 +439,12 @@ } } } + }, + "^[%@ message](inflect: true)" : { + + }, + "^[%@ token](inflect: true)" : { + }, "© 2026 [Rune Olsen](https://blog.rune.pm)" : { "comment" : "A copyright notice with the copyright holder's name.", @@ -521,6 +534,7 @@ }, "⌘N New • ⌘M Model • ⌘S Save" : { "comment" : "A hint that appears on macOS when using keyboard shortcuts.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "da" : { @@ -549,6 +563,10 @@ } } }, + "⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : { + "comment" : "A warning displayed in the settings view.", + "isCommentAutoGenerated" : true + }, "⚠️ Custom prompt active — only this prompt will be sent to the model." : { "localizations" : { "da" : { @@ -894,6 +912,12 @@ } } } + }, + "🧠" : { + + }, + "1. Open Anytype → Settings → Integrations" : { + }, "1. Open Paperless-NGX → Settings → API Tokens" : { "comment" : "A step in the process of getting a Paperless-NGX API token.", @@ -925,6 +949,10 @@ } } }, + "2. Create a new API key" : { + "comment" : "A step in the process of getting an API key from Anytype.", + "isCommentAutoGenerated" : true + }, "2. Create or copy your token" : { "comment" : "A step in the process of getting a Paperless-NGX API token.", "isCommentAutoGenerated" : true, @@ -1132,6 +1160,9 @@ } } } + }, + "Agent" : { + }, "Agent Skills" : { "localizations" : { @@ -1160,6 +1191,9 @@ } } } + }, + "Agents" : { + }, "Allow Shell Command?" : { "comment" : "A title for a modal that asks the user if they want to allow a shell command.", @@ -1566,6 +1600,9 @@ } } } + }, + "Category" : { + }, "Changing these values affects how the AI generates responses. The defaults work well for most use cases." : { "localizations" : { @@ -1654,6 +1691,9 @@ } } } + }, + "Choose an agent from the list to view details and run history" : { + }, "Clear All" : { "comment" : "A button to clear all email activity logs.", @@ -1918,6 +1958,9 @@ } } } + }, + "Cost" : { + }, "Cost Examples" : { "comment" : "A heading for the cost examples of a model.", @@ -2092,6 +2135,9 @@ } } } + }, + "Disabled" : { + }, "Each command will require your approval before running." : { "localizations" : { @@ -2468,6 +2514,12 @@ } } } + }, + "Filter by Category" : { + + }, + "Generate an API key in your Jarvis settings and paste it above." : { + }, "Google (Gemini embedding)" : { "localizations" : { @@ -2526,6 +2578,12 @@ } } } + }, + "High (~80%)" : { + + }, + "How to get your API key:" : { + }, "How to get your API token:" : { "comment" : "A heading for a section that describes how to get your API token.", @@ -2640,6 +2698,9 @@ } } } + }, + "Input" : { + }, "Large files inflate the system prompt and may hit token limits." : { "comment" : "A warning displayed when a user adds a large file to a skill.", @@ -2726,6 +2787,9 @@ } } } + }, + "Low (~20%)" : { + }, "Lowercase letters, numbers, and hyphens only. No spaces." : { "comment" : "A description of the format of a shortcut's command.", @@ -2842,6 +2906,9 @@ } } } + }, + "Medium (~50%)" : { + }, "messages" : { "localizations" : { @@ -2870,6 +2937,9 @@ } } } + }, + "Minimal (~10%)" : { + }, "Model Context Protocol" : { "localizations" : { @@ -2928,6 +2998,9 @@ } } } + }, + "Model thinks internally but reasoning is not shown in chat" : { + }, "Multi-provider AI chat client" : { "comment" : "A description of oAI.", @@ -3018,6 +3091,9 @@ } } } + }, + "New Chat" : { + }, "No credit data available" : { "comment" : "A message displayed when there is no credit data available.", @@ -3196,6 +3272,9 @@ } } } + }, + "No runs yet" : { + }, "No shortcuts yet" : { "comment" : "A message displayed when a user has no shortcuts.", @@ -3347,6 +3426,10 @@ } } }, + "oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : { + "comment" : "A warning that Intel Macs are no longer supported.", + "isCommentAutoGenerated" : true + }, "Ollama (Local)" : { "comment" : "A label displayed above the credits information for the local Ollie.", "isCommentAutoGenerated" : true, @@ -3574,6 +3657,9 @@ } } } + }, + "OpenRouter Balance" : { + }, "OpenRouter Credits" : { "comment" : "A heading for the user's OpenRouter credits.", @@ -3604,6 +3690,12 @@ } } } + }, + "Output" : { + + }, + "Prompt" : { + }, "Read access (always enabled)" : { "localizations" : { @@ -3632,6 +3724,9 @@ } } } + }, + "Reasoning" : { + }, "Remote: %@" : { "localizations" : { @@ -3690,6 +3785,12 @@ } } } + }, + "Run History" : { + + }, + "Run some agents to see usage statistics" : { + }, "Running locally — no credits needed!" : { "comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.", @@ -3721,6 +3822,10 @@ } } }, + "Runs" : { + "comment" : "A column header for the number of runs.", + "isCommentAutoGenerated" : true + }, "Security Recommendation" : { "localizations" : { "da" : { @@ -3866,6 +3971,9 @@ } } } + }, + "Sort" : { + }, "SSH Key" : { "localizations" : { @@ -4180,6 +4288,9 @@ } } } + }, + "Thinking…" : { + }, "This default prompt is always included to ensure accurate, helpful responses." : { "localizations" : { @@ -4296,6 +4407,15 @@ } } } + }, + "Total" : { + + }, + "Total Credits" : { + + }, + "Total Used" : { + }, "Try adjusting your search or filters" : { "comment" : "A description of the error that occurs when no models match the user's search.", @@ -4444,6 +4564,9 @@ } } } + }, + "Usage" : { + }, "Use @filename to attach files to your message" : { "comment" : "A description of how to attach files to a message.", @@ -4565,6 +4688,7 @@ }, "v%@" : { "comment" : "A label showing the current version of oAI.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "da" : { @@ -4744,6 +4868,10 @@ } } } + }, + "β" : { + "comment" : "A beta badge.", + "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index f689610..ab14649 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -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, diff --git a/oAI/Services/EmbeddingService.swift b/oAI/Services/EmbeddingService.swift index 391f1d5..29b6f17 100644 --- a/oAI/Services/EmbeddingService.swift +++ b/oAI/Services/EmbeddingService.swift @@ -71,7 +71,7 @@ enum EmbeddingProvider { // MARK: - Embedding Service final class EmbeddingService { - static let shared = EmbeddingService() + nonisolated static let shared = EmbeddingService() private let settings = SettingsService.shared @@ -281,7 +281,7 @@ final class EmbeddingService { // MARK: - Similarity Calculation /// Calculate cosine similarity between two embeddings - func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { + nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { guard a.count == b.count else { Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)") return 0.0 diff --git a/oAI/Services/EncryptionService.swift b/oAI/Services/EncryptionService.swift index b23d92c..4ea0864 100644 --- a/oAI/Services/EncryptionService.swift +++ b/oAI/Services/EncryptionService.swift @@ -29,7 +29,7 @@ import CryptoKit import IOKit class EncryptionService { - static let shared = EncryptionService() + nonisolated static let shared = EncryptionService() private let salt = "oAI-secure-storage-v1" // App-specific salt private lazy var encryptionKey: SymmetricKey = { @@ -41,7 +41,7 @@ class EncryptionService { // MARK: - Public Interface /// Encrypt a string value - func encrypt(_ value: String) throws -> String { + nonisolated func encrypt(_ value: String) throws -> String { guard let data = value.data(using: .utf8) else { throw EncryptionError.invalidInput } @@ -55,7 +55,7 @@ class EncryptionService { } /// Decrypt a string value - func decrypt(_ encryptedValue: String) throws -> String { + nonisolated func decrypt(_ encryptedValue: String) throws -> String { guard let data = Data(base64Encoded: encryptedValue) else { throw EncryptionError.invalidInput } diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index 068c4c4..2605f35 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -212,7 +212,7 @@ class GitSyncService { // Check if conversation already exists (by ID) if let existingId = UUID(uuidString: export.id) { - if let existing = try? db.loadConversation(id: existingId) { + if (try? db.loadConversation(id: existingId)) != nil { // Already exists - skip log.debug("Skipping existing conversation: \(export.name)") skipped += 1 diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 67a1403..5191f70 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -300,6 +300,24 @@ class SettingsService { } } + /// Input bar height in points — default 80 + var inputBarHeight: Double { + get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 } + set { + cache["inputBarHeight"] = String(newValue) + DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue)) + } + } + + /// Whether the sidebar is visible — default true + var sidebarVisible: Bool { + get { cache["sidebarVisible"] != "false" } + set { + cache["sidebarVisible"] = String(newValue) + DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue)) + } + } + // MARK: - MCP Permissions var mcpCanWriteFiles: Bool { diff --git a/oAI/Services/UpdateCheckService.swift b/oAI/Services/UpdateCheckService.swift index d2d3c44..98f087f 100644 --- a/oAI/Services/UpdateCheckService.swift +++ b/oAI/Services/UpdateCheckService.swift @@ -35,6 +35,11 @@ final class UpdateCheckService { var updateAvailable: Bool = false var latestVersion: String? = nil + var downloadURL: URL? = nil + + // Manual check state — drives the update alert in ContentView + var isCheckingManually: Bool = false + var manualCheckMessage: String? = nil private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest" private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")! @@ -48,6 +53,24 @@ final class UpdateCheckService { } } + /// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage. + func checkForUpdatesManually() { + guard !isCheckingManually else { return } + isCheckingManually = true + Task.detached(priority: .background) { + await self.performCheck() + let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + await MainActor.run { + if self.updateAvailable, let v = self.latestVersion { + self.manualCheckMessage = String(localized: "Version \(v) is available.") + } else { + self.manualCheckMessage = String(localized: "You're up to date (v\(current)).") + } + self.isCheckingManually = false + } + } + } + private func performCheck() async { guard let url = URL(string: apiURL) else { return } @@ -69,9 +92,16 @@ final class UpdateCheckService { let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + // Extract direct DMG download URL from release assets + let dmgURL: URL? = (release["assets"] as? [[String: Any]])? + .first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") } + .flatMap { $0["browser_download_url"] as? String } + .flatMap { URL(string: $0) } + if isNewer(latestVer, than: currentVer) { await MainActor.run { self.latestVersion = latestVer + self.downloadURL = dmgURL self.updateAvailable = true } } diff --git a/oAI/Utilities/Logging.swift b/oAI/Utilities/Logging.swift index 7ca90e1..483fd92 100644 --- a/oAI/Utilities/Logging.swift +++ b/oAI/Utilities/Logging.swift @@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable { // MARK: - App Logger (wraps os.Logger + file) -struct AppLogger { - let osLogger: Logger +// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are +// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor. +struct AppLogger: Sendable { + let subsystem: String let category: String - func debug(_ message: String) { + nonisolated func debug(_ message: String) { FileLogger.shared.write(.debug, category: category, message: message) - osLogger.debug("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") } } - func info(_ message: String) { + nonisolated func info(_ message: String) { FileLogger.shared.write(.info, category: category, message: message) - osLogger.info("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") } } - func warning(_ message: String) { + nonisolated func warning(_ message: String) { FileLogger.shared.write(.warning, category: category, message: message) - osLogger.warning("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") } } - func error(_ message: String) { + nonisolated func error(_ message: String) { FileLogger.shared.write(.error, category: category, message: message) - osLogger.error("\(message, privacy: .public)") + Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") } } } // MARK: - Log Namespace enum Log { - private static let subsystem = "com.oai.oAI" + private nonisolated static let subsystem = "com.oai.oAI" - static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api") - static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database") - static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp") - static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings") - static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search") - static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui") - static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general") + nonisolated static let api = AppLogger(subsystem: subsystem, category: "api") + nonisolated static let db = AppLogger(subsystem: subsystem, category: "database") + nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp") + nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings") + nonisolated static let search = AppLogger(subsystem: subsystem, category: "search") + nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui") + nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index ab5a8e9..d398f93 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -1313,7 +1313,7 @@ Don't narrate future actions ("Let me...") - just use the tools. // Append the complete system prompt (default + custom) systemContent += "\n\n---\n\n" + effectiveSystemPrompt - var messagesToSend: [Message] = memoryEnabled + let messagesToSend: [Message] = memoryEnabled ? messages.filter { $0.role != .system } : [messages.last(where: { $0.role == .user })].compactMap { $0 } @@ -1659,7 +1659,7 @@ Don't narrate future actions ("Let me...") - just use the tools. if isOverloaded && attempt < maxAttempts && !Task.isCancelled { let delay = Double(1 << attempt) // 2s, 4s, 8s Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...") - await MainActor.run { + _ = await MainActor.run { showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))") } try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index 52180c1..74c6943 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -37,10 +37,6 @@ struct ChatView: View { HeaderView( provider: viewModel.currentProvider, model: viewModel.selectedModel, - stats: viewModel.sessionStats, - onlineMode: viewModel.onlineMode, - mcpEnabled: viewModel.mcpEnabled, - mcpStatus: viewModel.mcpStatus, onModelSelect: onModelSelect, onProviderChange: onProviderChange ) @@ -85,10 +81,13 @@ struct ChatView: View { InputBar( text: $viewModel.inputText, isGenerating: viewModel.isGenerating, - mcpStatus: viewModel.mcpStatus, onlineMode: viewModel.onlineMode, onSend: viewModel.sendMessage, - onCancel: viewModel.cancelGeneration + onCancel: viewModel.cancelGeneration, + onToggleOnline: { + viewModel.onlineMode.toggle() + SettingsService.shared.onlineMode = viewModel.onlineMode + } ) // Footer @@ -96,7 +95,9 @@ struct ChatView: View { stats: viewModel.sessionStats, conversationName: viewModel.currentConversationName, hasUnsavedChanges: viewModel.hasUnsavedChanges, - onQuickSave: viewModel.quickSave + onQuickSave: viewModel.quickSave, + onlineMode: viewModel.onlineMode, + mcpEnabled: viewModel.mcpEnabled ) } .background(Color.oaiBackground) diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift index 28dba53..0876801 100644 --- a/oAI/Views/Main/ContentView.swift +++ b/oAI/Views/Main/ContentView.swift @@ -2,7 +2,7 @@ // ContentView.swift // oAI // -// Root navigation container +// Root navigation container — NavigationSplitView with collapsible sidebar // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -24,30 +24,34 @@ import SwiftUI +#if os(macOS) +import Darwin // uname, sysctlbyname +#endif struct ContentView: View { @Environment(ChatViewModel.self) var chatViewModel + private var updateService = UpdateCheckService.shared + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @State private var showIntelWarning = false var body: some View { @Bindable var vm = chatViewModel - NavigationStack { + NavigationSplitView(columnVisibility: $columnVisibility) { + SidebarView() + .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340) + } detail: { ChatView( onModelSelect: { chatViewModel.showModelSelector = true }, onProviderChange: { newProvider in chatViewModel.changeProvider(newProvider) } ) - .navigationTitle("") - .toolbar { - #if os(macOS) - macOSToolbar - #endif - } } .frame(minWidth: 860, minHeight: 560) #if os(macOS) .onAppear { NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed } + checkIntelWarning() } .onKeyPress(.return, phases: .down) { press in if press.modifiers.contains(.command) { @@ -65,7 +69,6 @@ struct ContentView: View { let oldModel = chatViewModel.selectedModel chatViewModel.selectModel(model) chatViewModel.showModelSelector = false - // Trigger auto-save on model switch Task { await chatViewModel.onModelSwitch(from: oldModel, to: model) } @@ -113,125 +116,56 @@ struct ContentView: View { chatViewModel.inputText = input }) } + .alert("Intel Mac Support Ending", isPresented: $showIntelWarning) { + Button("Got It") { + UserDefaults.standard.set(true, forKey: "hasShownIntelWarning") + } + } message: { + Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.") + } + .alert("Software Update", isPresented: Binding( + get: { updateService.manualCheckMessage != nil }, + set: { if !$0 { updateService.manualCheckMessage = nil } } + )) { + if updateService.updateAvailable { + if let url = updateService.downloadURL { + Button("Download v\(updateService.latestVersion ?? "")") { + NSWorkspace.shared.open(url) + } + } + Button("Release Page") { updateService.openReleasesPage() } + Button("Later", role: .cancel) { } + } else { + Button("OK", role: .cancel) { } + } + } message: { + Text(updateService.manualCheckMessage ?? "") + } } #if os(macOS) - @ToolbarContentBuilder - private var macOSToolbar: some ToolbarContent { - let settings = SettingsService.shared - let showLabels = settings.showToolbarLabels - let iconSize = settings.toolbarIconSize + private func checkIntelWarning() { + guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return } + guard isIntelNative || isRosetta else { return } + showIntelWarning = true + } - ToolbarItemGroup(placement: .automatic) { - // New conversation - Button(action: { chatViewModel.newConversation() }) { - ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("n", modifiers: .command) - .help("New conversation") - - Button(action: { chatViewModel.showConversations = true }) { - ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("l", modifiers: .command) - .help("Saved conversations (Cmd+L)") - - Button(action: { chatViewModel.showHistory = true }) { - ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("h", modifiers: .command) - .help("Command history (Cmd+H)") - - Spacer() - - Button(action: { chatViewModel.showModelSelector = true }) { - ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("m", modifiers: .command) - .help("Select AI model (Cmd+M)") - - Button(action: { - if let model = chatViewModel.selectedModel { - chatViewModel.modelInfoTarget = model - } - }) { - ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("i", modifiers: .command) - .help("Model info (Cmd+I)") - .disabled(chatViewModel.selectedModel == nil) - - Button(action: { chatViewModel.showStats = true }) { - ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize) - } - .help("Session statistics") - - Button(action: { chatViewModel.showCredits = true }) { - ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize) - } - .help("Check API credits") - - Spacer() - - Button(action: { chatViewModel.showSettings = true }) { - ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut(",", modifiers: .command) - .help("Settings (Cmd+,)") - - Button(action: { chatViewModel.showHelp = true }) { - ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize) - } - .keyboardShortcut("/", modifiers: .command) - .help("Help & commands (Cmd+/)") + private var isIntelNative: Bool { + var systemInfo = utsname() + uname(&systemInfo) + let machine = withUnsafeBytes(of: &systemInfo.machine) { + String(cString: $0.bindMemory(to: CChar.self).baseAddress!) } + return machine.contains("x86_64") + } + + private var isRosetta: Bool { + var ret: Int32 = 0 + var size = MemoryLayout.size + sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0) + return ret == 1 } #endif - -} - -// Helper view for toolbar labels -struct ToolbarLabel: View { - let title: LocalizedStringKey - let systemImage: String - let showLabels: Bool - let iconSize: Double - - // imageScale for the original range (≤32); explicit font size for the new extra-large range (>32) - private var scale: Image.Scale { - switch iconSize { - case ...18: return .small - case 19...24: return .medium - default: return .large - } - } - - var body: some View { - if iconSize > 32 { - // Extra-large: explicit font size above the system .large ceiling - // Offset by 16 so slider 34→18pt, 36→20pt, 38→22pt, 40→24pt - if showLabels { - Label(title, systemImage: systemImage) - .labelStyle(.titleAndIcon) - .font(.system(size: iconSize - 16)) - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .font(.system(size: iconSize - 16)) - } - } else { - // Original behaviour — imageScale keeps existing look intact - if showLabels { - Label(title, systemImage: systemImage) - .labelStyle(.titleAndIcon) - .imageScale(scale) - } else { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .imageScale(scale) - } - } - } } #Preview { diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift index 090f200..178be1d 100644 --- a/oAI/Views/Main/FooterView.swift +++ b/oAI/Views/Main/FooterView.swift @@ -30,15 +30,22 @@ struct FooterView: View { let conversationName: String? let hasUnsavedChanges: Bool let onQuickSave: (() -> Void)? + let onlineMode: Bool + let mcpEnabled: Bool + private let settings = SettingsService.shared init(stats: SessionStats, conversationName: String? = nil, hasUnsavedChanges: Bool = false, - onQuickSave: (() -> Void)? = nil) { + onQuickSave: (() -> Void)? = nil, + onlineMode: Bool = false, + mcpEnabled: Bool = false) { self.stats = stats self.conversationName = conversationName self.hasUnsavedChanges = hasUnsavedChanges self.onQuickSave = onQuickSave + self.onlineMode = onlineMode + self.mcpEnabled = mcpEnabled } var body: some View { @@ -71,6 +78,21 @@ struct FooterView: View { Spacer() + // Status pills — Online, MCP, Sync + #if os(macOS) + HStack(spacing: 6) { + if onlineMode { + StatusPill(icon: "globe", label: "Online", color: .green) + } + if mcpEnabled { + StatusPill(icon: "folder", label: "MCP", color: .blue) + } + if settings.syncEnabled && settings.syncAutoSave { + SyncStatusPill() + } + } + #endif + // Save indicator (only when chat has messages) if stats.messageCount > 0 { SaveIndicator( @@ -80,17 +102,11 @@ struct FooterView: View { ) } - // Update available badge + // Update available badge (shows only when an update exists — no version number) #if os(macOS) UpdateBadge() #endif - // Shortcuts hint - #if os(macOS) - Text("⌘N New • ⌘M Model • ⌘S Save") - .font(.caption2) - .foregroundColor(.oaiSecondary) - #endif } .padding(.horizontal, 16) .padding(.vertical, 8) @@ -242,7 +258,6 @@ struct SyncStatusFooter: View { struct UpdateBadge: View { private let updater = UpdateCheckService.shared - private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" var body: some View { if updater.updateAvailable { @@ -258,10 +273,6 @@ struct UpdateBadge: View { } .buttonStyle(.plain) .help("A new version is available — click to open the releases page") - } else { - Text("v\(currentVersion)") - .font(.caption2) - .foregroundColor(.oaiSecondary) } } } diff --git a/oAI/Views/Main/HeaderView.swift b/oAI/Views/Main/HeaderView.swift index 741be09..4280a9d 100644 --- a/oAI/Views/Main/HeaderView.swift +++ b/oAI/Views/Main/HeaderView.swift @@ -2,7 +2,8 @@ // HeaderView.swift // oAI // -// Header bar with provider, model, and stats +// Slim header — provider, model name, star only. +// Status pills and stats live in SidebarView and FooterView respectively. // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -28,18 +29,13 @@ import SwiftUI struct HeaderView: View { let provider: Settings.Provider let model: ModelInfo? - let stats: SessionStats - let onlineMode: Bool - let mcpEnabled: Bool - let mcpStatus: String? let onModelSelect: () -> Void let onProviderChange: (Settings.Provider) -> Void private let settings = SettingsService.shared private let registry = ProviderRegistry.shared - private let gitSync = GitSyncService.shared var body: some View { - HStack(spacing: 20) { + HStack(spacing: 12) { // Provider picker dropdown — only shows configured providers Menu { ForEach(registry.configuredProviders, id: \.self) { p in @@ -75,7 +71,7 @@ struct HeaderView: View { .fixedSize() .help("Switch provider") - // Model info (clickable → model selector) + // Model name (clickable → model selector) Button(action: onModelSelect) { if let model = model { HStack(spacing: 6) { @@ -116,7 +112,6 @@ struct HeaderView: View { Text("No model selected") .font(.system(size: settings.guiTextSize)) .foregroundColor(.oaiSecondary) - Image(systemName: "chevron.down") .font(.caption2) .foregroundColor(.oaiSecondary) @@ -126,6 +121,7 @@ struct HeaderView: View { .buttonStyle(.plain) .help("Select model") + // Favourite star if let model = model { let isFav = settings.favoriteModelIds.contains(model.id) Button(action: { settings.toggleFavoriteModel(model.id) }) { @@ -138,40 +134,9 @@ struct HeaderView: View { } Spacer() - - // Status indicators - HStack(spacing: 8) { - if model?.capabilities.imageGeneration == true { - StatusPill(icon: "paintbrush", label: "Image", color: .purple) - } - if onlineMode { - StatusPill(icon: "globe", label: "Online", color: .green) - } - if mcpEnabled { - StatusPill(icon: "folder", label: "MCP", color: .blue) - } - if settings.syncEnabled && settings.syncAutoSave { - SyncStatusPill() - } - } - - // Divider between status and stats - if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) { - Divider() - .frame(height: 16) - .opacity(0.5) - } - - // Quick stats - HStack(spacing: 16) { - StatItem(icon: "message", value: "\(stats.messageCount)") - StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay) - StatItem(icon: "dollarsign", value: stats.totalCostDisplay) - } - .font(.caption) } .padding(.horizontal, 16) - .padding(.vertical, 10) + .padding(.vertical, 8) .background(.ultraThinMaterial) .overlay( Rectangle() @@ -182,22 +147,7 @@ struct HeaderView: View { } } -struct StatItem: View { - let icon: String - let value: String - private let settings = SettingsService.shared - - var body: some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: settings.guiTextSize - 3)) - .foregroundColor(.oaiSecondary) - Text(value) - .font(.system(size: settings.guiTextSize - 1, weight: .medium)) - .foregroundColor(.oaiPrimary) - } - } -} +// MARK: - Status Pills (used by SidebarView) struct StatusPill: View { let icon: String @@ -284,15 +234,6 @@ struct SyncStatusPill: View { HeaderView( provider: .openrouter, model: ModelInfo.mockModels.first, - stats: SessionStats( - totalInputTokens: 125, - totalOutputTokens: 434, - totalCost: 0.00111, - messageCount: 4 - ), - onlineMode: true, - mcpEnabled: true, - mcpStatus: "MCP", onModelSelect: {}, onProviderChange: { _ in } ) diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift index 7bc7181..9985223 100644 --- a/oAI/Views/Main/InputBar.swift +++ b/oAI/Views/Main/InputBar.swift @@ -2,7 +2,7 @@ // InputBar.swift // oAI // -// Message input bar with status indicators +// Message input bar with resizable height and online toggle // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen @@ -24,20 +24,31 @@ import SwiftUI +#if os(macOS) +import AppKit +#endif struct InputBar: View { @Binding var text: String let isGenerating: Bool - let mcpStatus: String? let onlineMode: Bool let onSend: () -> Void let onCancel: () -> Void + let onToggleOnline: () -> Void private let settings = SettingsService.shared + + // Resizable input height — persisted to settings + @State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) + @State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight) + @State private var showCommandDropdown = false @State private var selectedSuggestionIndex: Int = 0 @FocusState private var isInputFocused: Bool + private static let minInputHeight: CGFloat = 56 + private static let maxInputHeight: CGFloat = 320 + /// Commands that execute immediately without additional arguments private static let immediateCommands: Set = [ "/help", "/history", "/model", "/clear", "/retry", "/stats", "/config", @@ -56,48 +67,42 @@ struct InputBar: View { CommandSuggestionsView( searchText: text, selectedIndex: selectedSuggestionIndex, - onSelect: { command in - selectCommand(command) - } + onSelect: selectCommand ) .frame(width: 400) .frame(maxHeight: 200) .transition(.move(edge: .bottom).combined(with: .opacity)) Spacer() } - .padding(.leading, 96) // Align with input box (status badges + spacing) + .padding(.leading, 16) } - // Input area - HStack(alignment: .bottom, spacing: 12) { - // Status indicators - HStack(spacing: 6) { - if let mcp = mcpStatus { - StatusBadge(text: mcp, color: .blue) - } - if onlineMode { - StatusBadge(text: "🌐", color: .green) - } - } - .frame(width: 80, alignment: .leading) + // Drag-to-resize handle + dragHandle - // Text input + // Input row + HStack(alignment: .bottom, spacing: 12) { + // Text input with globe toggle in bottom-left corner ZStack(alignment: .topLeading) { + // Placeholder if text.isEmpty { Text("Type a message or / for commands...") .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiSecondary) .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.top, 10) + .allowsHitTesting(false) } + // Editor — fills the fixed-height box, bottom area reserved for globe TextEditor(text: $text) .font(.system(size: settings.inputTextSize)) .foregroundColor(.oaiPrimary) .scrollContentBackground(.hidden) - .frame(minHeight: 44, maxHeight: 120) .padding(.horizontal, 8) - .padding(.vertical, 6) + .padding(.top, 6) + .padding(.bottom, 30) + .frame(maxWidth: .infinity, maxHeight: .infinity) .focused($isInputFocused) .onChange(of: text) { showCommandDropdown = text.hasPrefix("/") @@ -105,7 +110,6 @@ struct InputBar: View { } #if os(macOS) .onKeyPress(.upArrow) { - // Navigate command dropdown if showCommandDropdown && selectedSuggestionIndex > 0 { selectedSuggestionIndex -= 1 return .handled @@ -113,7 +117,6 @@ struct InputBar: View { return .ignored } .onKeyPress(.downArrow) { - // Navigate command dropdown if showCommandDropdown { let count = CommandSuggestionsView.filteredCommands(for: text).count if selectedSuggestionIndex < count - 1 { @@ -124,25 +127,12 @@ struct InputBar: View { return .ignored } .onKeyPress(.escape) { - // If command dropdown is showing, close it - if showCommandDropdown { - showCommandDropdown = false - return .handled - } - // If model is generating, cancel it - if isGenerating { - onCancel() - return .handled - } + if showCommandDropdown { showCommandDropdown = false; return .handled } + if isGenerating { onCancel(); return .handled } return .ignored } .onKeyPress(.return, phases: .down) { press in - // Shift+Return: always insert newline (let system handle) - if press.modifiers.contains(.shift) { - return .ignored - } - - // If command dropdown is showing, select the highlighted command + if press.modifiers.contains(.shift) { return .ignored } if showCommandDropdown { let suggestions = CommandSuggestionsView.filteredCommands(for: text) if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count { @@ -150,27 +140,40 @@ struct InputBar: View { return .handled } } - // Return (plain or with Cmd): send message - if !text.isEmpty { - onSend() - return .handled - } - // Empty text: do nothing + if !text.isEmpty { onSend(); return .handled } return .handled } #endif + + // Online / offline toggle — bottom-left of the text box + VStack { + Spacer() + HStack { + Button(action: onToggleOnline) { + Image(systemName: onlineMode ? "globe" : "network.slash") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(onlineMode ? Color.green : Color.secondary) + .padding(8) + } + .buttonStyle(.plain) + .help(onlineMode + ? "Online mode on — click to go offline" + : "Offline — click to go online") + Spacer() + } + } } + .frame(height: inputHeight) .background(Color.oaiSurface) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1) ) - - // Action buttons + + // Send / stop + attach buttons VStack(spacing: 8) { #if os(macOS) - // File attach button Button(action: pickFile) { Image(systemName: "paperclip") .font(.title2) @@ -209,21 +212,47 @@ struct InputBar: View { } } + // MARK: - Drag handle + + private var dragHandle: some View { + Color.clear + .frame(height: 8) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .overlay { + Capsule() + .fill(Color.secondary.opacity(0.25)) + .frame(width: 36, height: 3) + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + let proposed = dragStartHeight - value.translation.height + inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed)) + } + .onEnded { _ in + dragStartHeight = inputHeight + settings.inputBarHeight = Double(inputHeight) + } + ) + #if os(macOS) + .onHover { hovering in + if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() } + } + #endif + } + + // MARK: - Helpers + private func selectCommand(_ command: String) { showCommandDropdown = false if Self.immediateCommands.contains(command) { - // Execute immediately text = command onSend() } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { - if shortcut.needsInput { - text = command + " " - } else { - text = command - onSend() - } + text = shortcut.needsInput ? command + " " : command + if !shortcut.needsInput { onSend() } } else { - // Put in input for user to complete text = command + " " } } @@ -235,36 +264,14 @@ struct InputBar: View { panel.canChooseDirectories = false panel.canChooseFiles = true panel.message = "Select files to attach" - guard panel.runModal() == .OK else { return } - - let paths = panel.urls.map { $0.path } - // Use @ format (angle brackets) to safely handle paths with spaces - let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ") - - if text.isEmpty { - text = attachmentText + " " - } else { - text += " " + attachmentText - } + let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ") + text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText } #endif } -struct StatusBadge: View { - let text: String - let color: Color - - var body: some View { - Text(text) - .font(.caption) - .foregroundColor(color) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(color.opacity(0.15)) - .cornerRadius(4) - } -} +// MARK: - Command suggestions struct CommandSuggestionsView: View { let searchText: String @@ -304,10 +311,9 @@ struct CommandSuggestionsView: View { ] static func allCommands() -> [(command: String, description: LocalizedStringKey)] { - let shortcuts = SettingsService.shared.userShortcuts.map { s in + SettingsService.shared.userShortcuts.map { s in (s.command, LocalizedStringKey("⚡ \(s.description)")) - } - return builtInCommands + shortcuts + } + builtInCommands } static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] { @@ -344,26 +350,20 @@ struct CommandSuggestionsView: View { .id(suggestion.command) if index < suggestions.count - 1 { - Divider() - .background(Color.oaiBorder) + Divider().background(Color.oaiBorder) } } } } .onChange(of: selectedIndex) { if selectedIndex < suggestions.count { - withAnimation { - proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) - } + withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) } } } } .background(Color.oaiSurface) .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.oaiBorder, lineWidth: 1) - ) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1)) } } @@ -373,10 +373,10 @@ struct CommandSuggestionsView: View { InputBar( text: .constant(""), isGenerating: false, - mcpStatus: "📁 Files", onlineMode: true, onSend: {}, - onCancel: {} + onCancel: {}, + onToggleOnline: {} ) } .background(Color.oaiBackground) diff --git a/oAI/Views/Main/SidebarView.swift b/oAI/Views/Main/SidebarView.swift new file mode 100644 index 0000000..eb73e14 --- /dev/null +++ b/oAI/Views/Main/SidebarView.swift @@ -0,0 +1,216 @@ +// +// SidebarView.swift +// oAI +// +// Collapsible sidebar: new chat, conversation list, status pills +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import SwiftUI +#if os(macOS) +import AppKit +#endif + +struct SidebarView: View { + @Environment(ChatViewModel.self) private var chatViewModel + @State private var conversations: [Conversation] = [] + @State private var searchText = "" + + private var filteredConversations: [Conversation] { + guard !searchText.isEmpty else { return conversations } + return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) } + } + + var body: some View { + VStack(spacing: 0) { + // New Chat button + Button(action: { chatViewModel.newConversation() }) { + HStack(spacing: 8) { + Image(systemName: "square.and.pencil") + .font(.system(size: 14)) + Text("New Chat") + .font(.system(size: 14, weight: .medium)) + Spacer() + } + .foregroundColor(.oaiPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + // Search field + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + TextField("Search conversations…", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 13)) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + Divider().frame(height: 12) + Button { + chatViewModel.showConversations = true + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Advanced search — semantic search, bulk delete, export") + } + .padding(7) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + .padding(.horizontal, 8) + .padding(.bottom, 6) + + Divider() + + // Conversation list + if filteredConversations.isEmpty { + Spacer() + VStack(spacing: 8) { + Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass") + .font(.title2) + .foregroundStyle(.tertiary) + Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + } else { + List { + ForEach(filteredConversations) { conversation in + SidebarConversationRow(conversation: conversation) + .contentShape(Rectangle()) + .onTapGesture { + chatViewModel.loadConversation(conversation) + } + .listRowBackground( + chatViewModel.currentConversationName == conversation.name + ? Color.oaiAccent.opacity(0.15) + : Color.clear + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + deleteConversation(conversation) + } label: { + Label("Delete", systemImage: "trash") + } + Button { + renameConversation(conversation) + } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.orange) + } + } + } + .listStyle(.sidebar) + } + + } + .onAppear { loadConversations() } + .onChange(of: chatViewModel.currentConversationName) { loadConversations() } + .onChange(of: chatViewModel.messages.count) { loadConversations() } + } + + private func loadConversations() { + conversations = (try? DatabaseService.shared.listConversations()) ?? [] + } + + private func deleteConversation(_ conversation: Conversation) { + _ = try? DatabaseService.shared.deleteConversation(id: conversation.id) + withAnimation { + conversations.removeAll { $0.id == conversation.id } + } + } + + private func renameConversation(_ conversation: Conversation) { + #if os(macOS) + let alert = NSAlert() + alert.messageText = "Rename Conversation" + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") + let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24)) + input.stringValue = conversation.name + input.selectText(nil) + alert.accessoryView = input + alert.window.initialFirstResponder = input + guard alert.runModal() == .alertFirstButtonReturn else { return } + let newName = input.stringValue.trimmingCharacters(in: .whitespaces) + guard !newName.isEmpty, newName != conversation.name else { return } + do { + _ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil) + if let i = conversations.firstIndex(where: { $0.id == conversation.id }) { + conversations[i].name = newName + conversations[i].updatedAt = Date() + } + chatViewModel.didRenameConversation(id: conversation.id, newName: newName) + } catch { + Log.db.error("Failed to rename conversation: \(error.localizedDescription)") + } + #endif + } +} + +// MARK: - Sidebar conversation row + +struct SidebarConversationRow: View { + let conversation: Conversation + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateFormat = "dd.MM.yyyy" + return formatter.string(from: conversation.updatedAt) + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(conversation.name) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + HStack(spacing: 4) { + Text("^[\(conversation.messageCount) message](inflect: true)") + .font(.system(size: 11)) + Text("·") + .font(.system(size: 11)) + Text(formattedDate) + .font(.system(size: 11)) + } + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } +} + +#Preview { + SidebarView() + .environment(ChatViewModel()) + .frame(width: 240, height: 600) +} diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index abdd8b6..2717fe3 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -92,6 +92,8 @@ struct oAIApp: App { CommandGroup(after: .newItem) { Button("Open Chat…") { chatViewModel.showConversations = true } .keyboardShortcut("o", modifiers: .command) + Button("Search Conversations") { chatViewModel.showConversations = true } + .keyboardShortcut("l", modifiers: .command) } CommandGroup(replacing: .saveItem) { @@ -113,10 +115,44 @@ struct oAIApp: App { .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) } + // ── View menu ───────────────────────────────────────────────── + CommandMenu("View") { + Button("Select Model") { chatViewModel.showModelSelector = true } + .keyboardShortcut("m", modifiers: .command) + + Button("Model Info") { + chatViewModel.modelInfoTarget = chatViewModel.selectedModel + } + .keyboardShortcut("i", modifiers: .command) + .disabled(chatViewModel.selectedModel == nil) + + Divider() + + Button("Command History") { chatViewModel.showHistory = true } + .keyboardShortcut("h", modifiers: .command) + + Button("In-App Help") { chatViewModel.showHelp = true } + .keyboardShortcut("/", modifiers: .command) + + Button("Credits") { chatViewModel.showCredits = true } + + Divider() + + Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") { + chatViewModel.onlineMode.toggle() + } + .keyboardShortcut("o", modifiers: [.command, .shift]) + } + // ── Help menu ───────────────────────────────────────────────── CommandGroup(replacing: .help) { Button("oAI Help") { openHelp() } .keyboardShortcut("?", modifiers: .command) + Divider() + Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") { + UpdateCheckService.shared.checkForUpdatesManually() + } + .disabled(UpdateCheckService.shared.isCheckingManually) } } #endif