diff --git a/CHANGELOG.md b/CHANGELOG.md index 826f1a9c..cf45282b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ details, release history over commit history. app-server provider and deliver text-only Chat through models available to an eligible signed-in ChatGPT account. Native Settings keeps this under Accounts, separate from OpenAI Platform keys, and receives only normalized account/model state—never tokens. The provider must be explicitly - added as `codex-app-server`; signing in does not change Automatic routing, make it a preferred + added as `codex-app-server`; the native Connections screen can add that route with one click before + sign-in. Signing in does not change Automatic routing, make it a preferred hosted destination, or expand the credential broker. Offline mode excludes it, and a pinned ChatGPT destination fails visibly instead of falling back. Development builds can use an explicitly selected helper; release builds reject unverified sibling executables, and the ChatGPT-app @@ -26,11 +27,13 @@ details, release history over commit history. macOS app now includes a dedicated, thread-first Chat window that streams replies through the bundled arm64 Rust gateway. This first desktop release supports Apple Silicon on macOS 14 or later; Intel and a universal artifact are deferred. The complete conversation stays central while - the authoritative provider, mode, score, explanation, and routing signals live in a persistent, - collapsible inspector on the right. - Navigator filters never remove messages from the transcript. Conversations remain in memory for - this release; Stop, contextual Retry, New Chat, bounded request history, and actionable delivery - failures are included. The app uses its own SemVer release line (`0.1.0`) while the bundled router + the authoritative provider, mode, score, explanation, and routing signals remain available from + an information popover on each response instead of consuming permanent thread width. + Navigator filters never remove messages from the transcript. Conversation history persists + locally across New Chat and relaunch; Stop, contextual Retry, New Chat, bounded request history, + personal destination names, and actionable delivery failures are included. Settings consolidates + ChatGPT accounts and API-key providers under Connections while keeping their authentication paths + visibly distinct. The app uses its own SemVer release line (`0.1.0`) while the bundled router retains its existing CalVer identity when built and distributed independently; the router embedded in the desktop bundle reports the desktop product version. diff --git a/docs/releases/desktop-v0.1.0.md b/docs/releases/desktop-v0.1.0.md index b3588af0..11d6dbe9 100644 --- a/docs/releases/desktop-v0.1.0.md +++ b/docs/releases/desktop-v0.1.0.md @@ -2,15 +2,16 @@ Wayfinder's first native desktop release is a focused Apple Silicon menu-bar app with its Rust gateway built in. It keeps routing visible without turning the popover into a dashboard, and adds a -dedicated thread-first Chat window with detailed routing information in a collapsible right-hand -inspector. +dedicated thread-first Chat window where routing details stay secondary in a per-response popover. ## Included - Native menu-bar status, setup, Settings, endpoint readiness, routing controls, Offline mode, and Keychain-backed provider configuration. -- Focused Chat with streaming, Stop, Retry, New Chat, bounded in-memory history, explicit destination - selection, and truthful failure/recovery states. +- Focused Chat with streaming, Stop, Retry, New Chat, locally persisted conversation history, + explicit destination selection, personal destination names, and truthful failure/recovery states. +- A consolidated Connections screen for ChatGPT account access and API-key providers, including + one-click ChatGPT route configuration before the native sign-in flow. - The arm64 Rust gateway and authenticated Credential Broker and Foundation Model Broker XPC services inside the desktop app, all on the same `0.1.0` product version. - Apple Foundation Models as an on-device provider on eligible macOS 26+ Apple Silicon systems where @@ -46,7 +47,8 @@ keys remain a separate provider path, and Wayfinder never imports ChatGPT tokens ## Current boundaries -- Conversations are kept in memory for this release. +- Conversation history is stored locally on this Mac. It is not synced or sent anywhere merely by + being retained. - Chat remains a thin client over the bundled gateway; it is not an agent, tool runner, filesystem client, or credential owner. - The ChatGPT provider depends on verified `/Applications/ChatGPT.app` and is not self-contained. diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatConversation.swift b/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatConversation.swift new file mode 100644 index 00000000..ba2f0a8a --- /dev/null +++ b/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatConversation.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct ChatConversation: Identifiable, Codable, Equatable, Sendable { + public let id: UUID + public var messages: [ChatMessage] + public let createdAt: Date + public var updatedAt: Date + + public init( + id: UUID = UUID(), + messages: [ChatMessage] = [], + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.messages = messages + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + public var title: String { + messages.first(where: { $0.role == .user })?.text + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? "New chat" + } + + public var turnCount: Int { + messages.filter { $0.role == .user }.count + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Models/ProviderCredentialDetail.swift b/macos/WayfinderMac/Sources/WayfinderMac/Models/ProviderCredentialDetail.swift index ce1ceb86..b678adae 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/Models/ProviderCredentialDetail.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/Models/ProviderCredentialDetail.swift @@ -32,9 +32,9 @@ public enum CredentialStatus: Equatable, Sendable { case .unknown: return "Checking" case .keyMissing: - return "Key missing" + return "Not connected" case .keyPresent: - return "Key present" + return "Connected" case .local: return "Local" case .comingSoon: @@ -87,8 +87,8 @@ public extension ProviderKind { ) case .openAI: return ProviderCredentialDetail( - displayName: "OpenAI", - providerName: "openai", + displayName: "OpenAI Platform API", + providerName: "OpenAI API", baseURL: "https://api.openai.com/v1", models: [ "gpt-4o-mini", diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Services/ChatConversationStore.swift b/macos/WayfinderMac/Sources/WayfinderMac/Services/ChatConversationStore.swift new file mode 100644 index 00000000..6b7ed8e4 --- /dev/null +++ b/macos/WayfinderMac/Sources/WayfinderMac/Services/ChatConversationStore.swift @@ -0,0 +1,64 @@ +import Foundation + +public final class ChatConversationStore: @unchecked Sendable { + private static let maximumStoredConversations = 100 + private static let maximumFileBytes = 8 * 1_024 * 1_024 + + private let fileURL: URL? + private let fileManager: FileManager + + public init(fileURL: URL? = nil, fileManager: FileManager = .default) { + self.fileURL = fileURL + self.fileManager = fileManager + } + + public static func applicationSupport(fileManager: FileManager = .default) -> ChatConversationStore { + let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + return ChatConversationStore( + fileURL: root? + .appendingPathComponent("Wayfinder", isDirectory: true) + .appendingPathComponent("chat-history.json", isDirectory: false), + fileManager: fileManager + ) + } + + public func load() -> [ChatConversation] { + guard let fileURL, + let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), + let byteCount = attributes[.size] as? NSNumber, + byteCount.intValue <= Self.maximumFileBytes, + let data = try? Data(contentsOf: fileURL, options: .mappedIfSafe), + let conversations = try? JSONDecoder().decode([ChatConversation].self, from: data) else { + return [] + } + return Array( + conversations + .filter { !$0.messages.isEmpty } + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(Self.maximumStoredConversations) + ) + } + + public func save(_ conversations: [ChatConversation]) { + guard let fileURL else { return } + let bounded = Array( + conversations + .filter { !$0.messages.isEmpty } + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(Self.maximumStoredConversations) + ) + guard let data = try? JSONEncoder().encode(bounded), + data.count <= Self.maximumFileBytes else { + return + } + do { + try fileManager.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: fileURL, options: [.atomic, .completeFileProtection]) + } catch { + // Chat remains usable in memory if local history cannot be written. + } + } +} diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Services/ChatGPTProviderConfigurator.swift b/macos/WayfinderMac/Sources/WayfinderMac/Services/ChatGPTProviderConfigurator.swift new file mode 100644 index 00000000..e16b4fd2 --- /dev/null +++ b/macos/WayfinderMac/Sources/WayfinderMac/Services/ChatGPTProviderConfigurator.swift @@ -0,0 +1,80 @@ +import Foundation + +public protocol ChatGPTProviderConfigurator: Sendable { + func configure() async throws +} + +public struct LocalChatGPTProviderConfigurator: ChatGPTProviderConfigurator { + private let resolver: GatewayToolResolver + private let service: GatewayServiceController + + public init( + resolver: GatewayToolResolver = GatewayToolResolver(), + service: GatewayServiceController = GatewayServiceController() + ) { + self.resolver = resolver + self.service = service + } + + public func configure() async throws { + let tool: URL + do { + tool = try await resolver.resolveGateway() + } catch { + throw ChatGPTProviderSetupError.gatewayMissing + } + let path = GatewayServiceController.defaultConfigPath() + let result = await Self.run(tool, ["app-configure-chatgpt", "--path", path]) + guard result.exitCode == 0 else { + throw ChatGPTProviderSetupError.configurationFailed(result.stderr) + } + try await service.restart() + for _ in 0..<20 { + if (await service.status()).health != nil { return } + try await Task.sleep(nanoseconds: 250_000_000) + } + throw ChatGPTProviderSetupError.gatewayDidNotRestart + } + + private static func run(_ executable: URL, _ arguments: [String]) async -> ChatGPTSetupProcessResult { + await Task.detached { + let process = Process() + process.executableURL = executable + process.arguments = arguments + let stderr = Pipe() + process.standardOutput = FileHandle(forWritingAtPath: "/dev/null") + process.standardError = stderr + do { try process.run() } catch { + return ChatGPTSetupProcessResult(exitCode: 1, stderr: error.localizedDescription) + } + process.waitUntilExit() + let data = stderr.fileHandleForReading.readDataToEndOfFile() + return ChatGPTSetupProcessResult( + exitCode: process.terminationStatus, + stderr: String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + ) + }.value + } +} + +private struct ChatGPTSetupProcessResult: Sendable { + let exitCode: Int32 + let stderr: String +} + +public enum ChatGPTProviderSetupError: LocalizedError, Sendable { + case gatewayMissing + case configurationFailed(String) + case gatewayDidNotRestart + + public var errorDescription: String? { + switch self { + case .gatewayMissing: + return "Wayfinder could not find its bundled gateway. Reinstall the app and try again." + case .configurationFailed(let detail): + return detail.isEmpty ? "Wayfinder could not add the ChatGPT destination." : detail + case .gatewayDidNotRestart: + return "ChatGPT was added, but the local gateway did not restart. Open Gateway settings and try again." + } + } +} diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Services/GatewayReplacementReconciler.swift b/macos/WayfinderMac/Sources/WayfinderMac/Services/GatewayReplacementReconciler.swift new file mode 100644 index 00000000..ac005c36 --- /dev/null +++ b/macos/WayfinderMac/Sources/WayfinderMac/Services/GatewayReplacementReconciler.swift @@ -0,0 +1,112 @@ +import Foundation + +public enum GatewayReplacementReconciliation: Equatable, Sendable { + case unchanged + case restarted + case deferred +} + +/// Restarts a loaded bundled gateway once after its containing app is replaced. +/// +/// launchd can keep the old executable image alive when an app is replaced at the same path. That +/// stale process can no longer authenticate newly signed nested XPC services. The helper is located +/// only at the fixed bundle-relative path, and the LaunchAgent must name that exact path before it +/// can be restarted. Full helper authentication still happens at every execution boundary; this +/// marker only avoids an unnecessary restart on every ordinary app launch. +public struct GatewayReplacementReconciler: Sendable { + public typealias GatewayLocator = @Sendable () -> URL? + public typealias StatusQuery = @Sendable (_ expectedGateway: URL) async -> GatewayServiceStatus + public typealias Restarter = @Sendable (_ expectedGateway: URL) async throws -> Void + public typealias FingerprintProvider = @Sendable (_ gateway: URL) -> String? + public typealias MarkerReader = @Sendable () -> String? + public typealias MarkerWriter = @Sendable (_ fingerprint: String) async -> Void + + private static let markerKey = "Wayfinder.GatewayInstalledFingerprint" + + private let locateGateway: GatewayLocator + private let statusQuery: StatusQuery + private let restart: Restarter + private let fingerprintProvider: FingerprintProvider + private let markerReader: MarkerReader + private let markerWriter: MarkerWriter + + public init( + appBundleURL: URL = Bundle.main.bundleURL, + service: GatewayServiceController = GatewayServiceController() + ) { + self.locateGateway = { + let gateway = GatewayToolResolver.bundledHelperURL(in: appBundleURL) + return FileManager.default.isExecutableFile(atPath: gateway.path) ? gateway : nil + } + self.statusQuery = { await service.status(expectedGateway: $0) } + self.restart = { try await service.restart(expectedGateway: $0) } + self.fingerprintProvider = { Self.installationFingerprint(gateway: $0) } + self.markerReader = { UserDefaults.standard.string(forKey: Self.markerKey) } + self.markerWriter = { UserDefaults.standard.set($0, forKey: Self.markerKey) } + } + + init( + locateGateway: @escaping GatewayLocator, + statusQuery: @escaping StatusQuery, + restart: @escaping Restarter, + fingerprintProvider: @escaping FingerprintProvider, + markerReader: @escaping MarkerReader, + markerWriter: @escaping MarkerWriter + ) { + self.locateGateway = locateGateway + self.statusQuery = statusQuery + self.restart = restart + self.fingerprintProvider = fingerprintProvider + self.markerReader = markerReader + self.markerWriter = markerWriter + } + + public func reconcile() async -> GatewayReplacementReconciliation { + guard let gateway = locateGateway(), + let fingerprint = fingerprintProvider(gateway) else { + return .deferred + } + if markerReader() == fingerprint { + return .unchanged + } + + let status = await statusQuery(gateway) + guard status.installed, + status.loaded, + status.launchConfiguration.usesGateway(at: gateway) else { + return .deferred + } + + do { + try await restart(gateway) + await markerWriter(fingerprint) + return .restarted + } catch { + return .deferred + } + } + + static func installationFingerprint( + appBundleURL: URL = Bundle.main.bundleURL, + gateway: URL + ) -> String? { + guard let values = try? gateway.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), + let fileSize = values.fileSize, + let modified = values.contentModificationDate else { + return nil + } + let info = Bundle(url: appBundleURL)?.infoDictionary ?? Bundle.main.infoDictionary ?? [:] + let version = info["CFBundleShortVersionString"] as? String ?? "unknown" + let build = info["CFBundleVersion"] as? String ?? "unknown" + let signing: String + switch AppleFoundationModelsProductReadiness.current(appBundleURL: appBundleURL) { + case .ready(let teamIdentifier): + signing = teamIdentifier + case .incompleteOrInvalid: + signing = "untrusted" + } + let modifiedNanoseconds = Int64(modified.timeIntervalSince1970 * 1_000_000_000) + return [version, build, signing, String(fileSize), String(modifiedNanoseconds)] + .joined(separator: "|") + } +} diff --git a/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift b/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift index 7b61634e..f9887590 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift @@ -12,24 +12,37 @@ public final class AppState: ObservableObject { @Published public var chatDestination: ChatDestination = .automatic @Published public private(set) var chatDestinations: [ChatDestination] = [.automatic] @Published public private(set) var chatMessages: [ChatMessage] + @Published public private(set) var chatConversations: [ChatConversation] + @Published public private(set) var activeChatConversationID: UUID @Published public private(set) var isSendingMessage = false @Published public private(set) var setupAssessment: SetupAssessment = .checking public let chatDestinationNameStore: ChatDestinationNameStore private let client: any WayfinderClient + private let chatConversationStore: ChatConversationStore private let setupService = SetupService() private var chatTask: Task? public init( client: any WayfinderClient, - chatDestinationNameStore: ChatDestinationNameStore? = nil + chatDestinationNameStore: ChatDestinationNameStore? = nil, + chatConversationStore: ChatConversationStore = ChatConversationStore() ) { self.client = client self.chatDestinationNameStore = chatDestinationNameStore ?? ChatDestinationNameStore() + self.chatConversationStore = chatConversationStore self.routingStats = .empty self.gatewayOverview = .checking - self.chatMessages = [] + let storedConversations = chatConversationStore.load() + self.chatConversations = storedConversations + if let mostRecent = storedConversations.first { + self.activeChatConversationID = mostRecent.id + self.chatMessages = mostRecent.messages + } else { + self.activeChatConversationID = UUID() + self.chatMessages = [] + } } public var canAnalyse: Bool { @@ -139,6 +152,7 @@ public final class AppState: ObservableObject { text: "", state: .streaming )) + saveActiveConversation() chatTask = Task { do { @@ -178,6 +192,7 @@ public final class AppState: ObservableObject { } self.isSendingMessage = false self.chatTask = nil + self.saveActiveConversation() self.refreshStats() } catch { if Task.isCancelled || error is CancellationError { @@ -200,11 +215,25 @@ public final class AppState: ObservableObject { chatTask?.cancel() } - public func clearChat() { + public func startNewChat() { guard canClearChat else { return } - chatMessages.removeAll() + saveActiveConversation() + activeChatConversationID = UUID() + chatMessages = [] + chatDraft = "" + } + + public func selectChatConversation(_ id: UUID) { + guard !isSendingMessage, id != activeChatConversationID, + let conversation = chatConversations.first(where: { $0.id == id }) else { + return + } + saveActiveConversation() + activeChatConversationID = conversation.id + chatMessages = conversation.messages + chatDraft = "" } public func retryLastChatTurn() { @@ -284,9 +313,9 @@ public final class AppState: ObservableObject { } switch clientError { case .chatAccountNotReady: - return .accounts + return .connections case .gatewayStatus(503, _) where destination.isChatGPTAccount: - return .accounts + return .connections default: return .gateway } @@ -318,6 +347,7 @@ public final class AppState: ObservableObject { } isSendingMessage = false chatTask = nil + saveActiveConversation() } private func finishFailedChatMessage( @@ -332,17 +362,33 @@ public final class AppState: ObservableObject { } isSendingMessage = false chatTask = nil + saveActiveConversation() refreshStats() } + + private func saveActiveConversation() { + guard !chatMessages.isEmpty else { return } + let now = Date() + if let index = chatConversations.firstIndex(where: { $0.id == activeChatConversationID }) { + chatConversations[index].messages = chatMessages + chatConversations[index].updatedAt = now + } else { + chatConversations.append(ChatConversation( + id: activeChatConversationID, + messages: chatMessages, + createdAt: chatMessages.first?.createdAt ?? now, + updatedAt: now + )) + } + chatConversations.sort { $0.updatedAt > $1.updatedAt } + chatConversationStore.save(chatConversations) + } } public enum SettingsSection: String, CaseIterable, Codable, Identifiable, Sendable { + case connections = "Connections" case gateway = "Gateway" case routing = "Routing" - case accounts = "Accounts" - case keys = "Keys" - case privacy = "Privacy" - case help = "Help" case about = "About" public var id: String { rawValue } @@ -353,23 +399,37 @@ public enum SettingsSection: String, CaseIterable, Codable, Identifiable, Sendab return "server.rack" case .routing: return "point.topleft.down.curvedto.point.bottomright.up" - case .accounts: - return "person.crop.circle" - case .keys: - return "key" - case .privacy: - return "shield" - case .help: - return "questionmark.circle" + case .connections: + return "link" case .about: return "info.circle" } } + + public init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + switch value { + case "Gateway": self = .gateway + case "Routing": self = .routing + case "Connections", "Accounts", "Keys": self = .connections + case "About", "Help", "Privacy": self = .about + default: + throw DecodingError.dataCorruptedError( + in: try decoder.singleValueContainer(), + debugDescription: "Unknown settings section" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } } public enum ProviderKind: String, CaseIterable, Identifiable, Sendable { case anthropic = "Anthropic" - case openAI = "OpenAI" + case openAI = "OpenAI API" case googleGemini = "Google Gemini" case ollama = "Ollama" case lmStudio = "LM Studio" diff --git a/macos/WayfinderMac/Sources/WayfinderMac/State/CodexAccountSettingsState.swift b/macos/WayfinderMac/Sources/WayfinderMac/State/CodexAccountSettingsState.swift index bd1c2f71..16651f43 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/State/CodexAccountSettingsState.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/State/CodexAccountSettingsState.swift @@ -2,6 +2,7 @@ import Foundation public enum CodexAccountViewState: Equatable, Sendable { case checking + case needsConfiguration case signedOut case awaitingBrowser(CodexPendingLogin) case awaitingDeviceCode(CodexPendingLogin) @@ -36,6 +37,7 @@ public final class CodexAccountSettingsState: ObservableObject { @Published public private(set) var modelCatalogError: String? private let client: any CodexAccountClient + private let configurator: any ChatGPTProviderConfigurator private let automaticallyPollLogin: Bool private let pollIntervalNanoseconds: UInt64 private let maximumPollCount: Int @@ -44,12 +46,14 @@ public final class CodexAccountSettingsState: ObservableObject { public init( client: any CodexAccountClient = GatewayCodexAccountClient(), + configurator: any ChatGPTProviderConfigurator = LocalChatGPTProviderConfigurator(), automaticallyPollLogin: Bool = true, pollIntervalNanoseconds: UInt64 = 1_000_000_000, maximumPollCount: Int = 300, onAccountStateChanged: @escaping @MainActor () -> Void = {} ) { self.client = client + self.configurator = configurator self.automaticallyPollLogin = automaticallyPollLogin self.pollIntervalNanoseconds = pollIntervalNanoseconds self.maximumPollCount = maximumPollCount @@ -66,8 +70,29 @@ public final class CodexAccountSettingsState: ObservableObject { do { let snapshot = try await client.account() await apply(snapshot, startPolling: true) + } catch { + if case CodexAccountClientError.gatewayStatus(404) = error { + state = .needsConfiguration + } else { + state = .failed(message: Self.message(for: error)) + } + } + } + + @discardableResult + public func configureAndBeginLogin() async -> URL? { + guard !isPerformingAction else { return nil } + isPerformingAction = true + defer { isPerformingAction = false } + do { + try await configurator.configure() + let snapshot = try await client.beginLogin(flow: .browser) + await apply(snapshot, startPolling: true) + if case .awaitingBrowser(let login) = state { return login.url } + return nil } catch { state = .failed(message: Self.message(for: error)) + return nil } } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift index bdae9217..7d97ccad 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatConversationView.swift @@ -5,7 +5,6 @@ public struct ChatConversationView: View { @Binding var selectedTurnID: UUID? let canRetry: Bool let onRetry: () -> Void - let onOpenRouting: (ChatTurn) -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var isNearBottom = true @@ -13,14 +12,12 @@ public struct ChatConversationView: View { turns: [ChatTurn], selectedTurnID: Binding, canRetry: Bool = false, - onRetry: @escaping () -> Void = {}, - onOpenRouting: @escaping (ChatTurn) -> Void = { _ in } + onRetry: @escaping () -> Void = {} ) { self.turns = turns self._selectedTurnID = selectedTurnID self.canRetry = canRetry self.onRetry = onRetry - self.onOpenRouting = onOpenRouting } public var body: some View { @@ -37,13 +34,8 @@ public struct ChatConversationView: View { ChatTurnHistoryRow( turn: turn, isLast: index == turns.count - 1, - isSelected: selectedTurnID == turn.id, canRetry: canRetry && index == turns.count - 1, - onRetry: onRetry, - onShowRouting: { - selectedTurnID = turn.id - onOpenRouting(turn) - } + onRetry: onRetry ) .id(turn.id) } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatSidebarView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatSidebarView.swift index 8da75c4f..4ed2992d 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatSidebarView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatSidebarView.swift @@ -1,33 +1,30 @@ import SwiftUI public struct ChatSidebarView: View { - let turns: [ChatTurn] - let visibleTurns: [ChatTurn] - @Binding var selectedTurnID: UUID? - @Binding var routeFilter: ChatRouteFilter + let conversations: [ChatConversation] + let activeConversationID: UUID @Binding var searchText: String let searchFocusRequest: Int + let isSending: Bool let onNewChat: () -> Void - let onSelectTurn: () -> Void + let onSelectConversation: (UUID) -> Void public init( - turns: [ChatTurn], - visibleTurns: [ChatTurn], - selectedTurnID: Binding, - routeFilter: Binding, + conversations: [ChatConversation], + activeConversationID: UUID, searchText: Binding, searchFocusRequest: Int = 0, + isSending: Bool = false, onNewChat: @escaping () -> Void = {}, - onSelectTurn: @escaping () -> Void = {} + onSelectConversation: @escaping (UUID) -> Void = { _ in } ) { - self.turns = turns - self.visibleTurns = visibleTurns - self._selectedTurnID = selectedTurnID - self._routeFilter = routeFilter + self.conversations = conversations + self.activeConversationID = activeConversationID self._searchText = searchText self.searchFocusRequest = searchFocusRequest + self.isSending = isSending self.onNewChat = onNewChat - self.onSelectTurn = onSelectTurn + self.onSelectConversation = onSelectConversation } public var body: some View { @@ -45,54 +42,60 @@ public struct ChatSidebarView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityHint("Clears the current in-memory conversation.") + .disabled(isSending) + .accessibilityHint("Starts a new conversation and keeps existing chats in history.") .padding(.horizontal, 14) .padding(.vertical, 9) - HStack { - Text("This chat") - .font(.caption2.weight(.semibold)) - .textCase(.uppercase) - .tracking(0.7) - .foregroundStyle(ChatWorkspaceChrome.tertiaryText) - Spacer() - RouteFilterMenu(selected: $routeFilter, turns: turns) - } - .padding(.horizontal, 14) - .padding(.bottom, 8) + Text("Chats") + .font(.caption2.weight(.semibold)) + .textCase(.uppercase) + .tracking(0.7) + .foregroundStyle(ChatWorkspaceChrome.tertiaryText) + .padding(.horizontal, 14) + .padding(.bottom, 8) - if visibleTurns.isEmpty { - SidebarEmptyState(hasHistory: !turns.isEmpty) + if visibleConversations.isEmpty { + SidebarEmptyState(hasHistory: !conversations.isEmpty) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.top, 24) } else { - List(selection: turnSelection) { - ForEach(visibleTurns) { turn in - SidebarTurnRow( - turn: turn, - isSelected: turn.id == selectedTurnID + List(selection: conversationSelection) { + ForEach(visibleConversations) { conversation in + SidebarConversationRow( + conversation: conversation, + isSelected: conversation.id == activeConversationID ) - .tag(turn.id) + .tag(conversation.id) } } .listStyle(.sidebar) .scrollContentBackground(.hidden) } - SidebarStatusFooter(turns: turns) + SidebarStatusFooter(conversationCount: conversations.count) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(ChatWorkspaceChrome.sidebar) } - private var turnSelection: Binding { - Binding( - get: { selectedTurnID }, - set: { turnID in - selectedTurnID = turnID - if turnID != nil { - onSelectTurn() + private var visibleConversations: [ChatConversation] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return conversations } + return conversations.filter { conversation in + conversation.title.localizedCaseInsensitiveContains(query) + || conversation.messages.contains { + $0.text.localizedCaseInsensitiveContains(query) } + } + } + + private var conversationSelection: Binding { + Binding( + get: { activeConversationID }, + set: { id in + guard let id else { return } + onSelectConversation(id) } ) } @@ -109,7 +112,7 @@ private struct SidebarHeader: View { .foregroundStyle(WayfinderTheme.local) .font(.system(size: 14, weight: .semibold)) } - Text("Current chat") + Text("Conversation history") .font(.caption) .foregroundStyle(ChatWorkspaceChrome.secondaryText) } @@ -117,7 +120,6 @@ private struct SidebarHeader: View { .padding(.top, 15) .padding(.bottom, 14) } - } private struct SearchField: View { @@ -130,11 +132,11 @@ private struct SearchField: View { Image(systemName: "magnifyingglass") .foregroundStyle(ChatWorkspaceChrome.tertiaryText) .accessibilityHidden(true) - TextField("Search turns", text: $text) + TextField("Search chats", text: $text) .textFieldStyle(.plain) .font(.caption) .focused($focused) - .accessibilityLabel("Search conversation turns") + .accessibilityLabel("Search conversations") if !text.isEmpty { Button { text = "" @@ -159,124 +161,50 @@ private struct SearchField: View { } } -private struct RouteFilterMenu: View { - @Binding var selected: ChatRouteFilter - let turns: [ChatTurn] - - var body: some View { - Menu { - ForEach(ChatRouteFilter.allCases) { filter in - Button { - selected = filter - } label: { - Text("\(filter.rawValue) (\(count(for: filter)))") - } - } - } label: { - HStack(spacing: 4) { - Text(selected.rawValue) - Text("\(count(for: selected))") - .monospacedDigit() - } - .font(.caption2) - .foregroundStyle(ChatWorkspaceChrome.secondaryText) - } - .menuStyle(.borderlessButton) - .fixedSize() - .accessibilityLabel("Route filter") - .accessibilityValue(selected.rawValue) - } - - private func count(for filter: ChatRouteFilter) -> Int { - turns.filter { filter.includes($0) }.count - } -} - private struct SidebarStatusFooter: View { - let turns: [ChatTurn] + let conversationCount: Int var body: some View { VStack(alignment: .leading, spacing: 9) { - Divider() - .overlay(ChatWorkspaceChrome.border) + Divider().overlay(ChatWorkspaceChrome.border) HStack { - Label("In memory", systemImage: "memorychip") + Label("On this Mac", systemImage: "internaldrive") .font(.caption2) Spacer() - Text(turnCountText) + Text(conversationCount == 1 ? "1 chat" : "\(conversationCount) chats") .font(.caption2.monospacedDigit()) } } .foregroundStyle(ChatWorkspaceChrome.secondaryText) .padding(18) } - - private var turnCountText: String { - turns.count == 1 ? "1 turn" : "\(turns.count) turns" - } - } -private struct SidebarTurnRow: View { - let turn: ChatTurn +private struct SidebarConversationRow: View { + let conversation: ChatConversation let isSelected: Bool var body: some View { - HStack(spacing: 9) { - Circle() - .fill(statusColor) - .frame(width: 7, height: 7) - - VStack(alignment: .leading, spacing: 2) { - Text(turn.prompt.text) - .lineLimit(1) - .foregroundStyle(.primary) - Text(subtitle) - .font(.caption2) - .foregroundStyle(ChatWorkspaceChrome.secondaryText) - .lineLimit(1) - } - - Spacer(minLength: 4) + VStack(alignment: .leading, spacing: 3) { + Text(conversation.title) + .lineLimit(1) + .foregroundStyle(.primary) + Text(subtitle) + .font(.caption2) + .foregroundStyle(ChatWorkspaceChrome.secondaryText) + .lineLimit(1) } .font(.caption) .padding(.vertical, 3) .accessibilityElement(children: .combine) - .accessibilityLabel("\(turn.prompt.text), \(subtitle)") + .accessibilityLabel("\(conversation.title), \(subtitle)") .accessibilityAddTraits(isSelected ? .isSelected : []) } private var subtitle: String { - let time = turn.prompt.createdAt.formatted(date: .omitted, time: .shortened) - guard let response = turn.response else { - return "Waiting for route · \(time)" - } - - switch response.state { - case .streaming: - return "Routing · \(time)" - case .failed: - return "Failed · \(time)" - case .stopped: - return "Stopped · \(time)" - case .complete: - return response.decision == nil ? "No route data · \(time)" : time - } - } - - private var statusColor: Color { - switch turn.response?.state { - case .failed: - return .red - case .stopped: - return ChatWorkspaceChrome.secondaryText - case .streaming, .none: - return WayfinderTheme.local - case .complete: - return turn.response?.decision == nil - ? ChatWorkspaceChrome.tertiaryText - : ChatWorkspaceChrome.secondaryText - } + let date = conversation.updatedAt.formatted(date: .abbreviated, time: .shortened) + let turns = conversation.turnCount == 1 ? "1 turn" : "\(conversation.turnCount) turns" + return "\(turns) · \(date)" } } @@ -290,10 +218,10 @@ private struct SidebarEmptyState: View { .font(.callout) .foregroundStyle(ChatWorkspaceChrome.tertiaryText) } - Text(hasHistory ? "No turns found" : "No turns yet") + Text(hasHistory ? "No chats found" : "No chats yet") .font(.caption.weight(.medium)) if hasHistory { - Text("Try another search or filter.") + Text("Try another search.") .font(.caption2) .foregroundStyle(ChatWorkspaceChrome.tertiaryText) } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift index 6d0cb723..55c5eb6e 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatTurnHistoryRow.swift @@ -3,25 +3,19 @@ import SwiftUI public struct ChatTurnHistoryRow: View { let turn: ChatTurn let isLast: Bool - let isSelected: Bool let canRetry: Bool let onRetry: () -> Void - let onShowRouting: () -> Void public init( turn: ChatTurn, isLast: Bool, - isSelected: Bool, canRetry: Bool, - onRetry: @escaping () -> Void, - onShowRouting: @escaping () -> Void + onRetry: @escaping () -> Void ) { self.turn = turn self.isLast = isLast - self.isSelected = isSelected self.canRetry = canRetry self.onRetry = onRetry - self.onShowRouting = onShowRouting } public var body: some View { @@ -30,11 +24,10 @@ public struct ChatTurnHistoryRow: View { if let response = turn.response { AssistantTurnResponse( + turn: turn, response: response, - isSelected: isSelected, canRetry: canRetry, - onRetry: onRetry, - onShowRouting: onShowRouting + onRetry: onRetry ) } else { PendingRouteStrip() @@ -70,11 +63,10 @@ public struct ChatTurnHistoryRow: View { } private struct AssistantTurnResponse: View { + let turn: ChatTurn let response: ChatMessage - let isSelected: Bool let canRetry: Bool let onRetry: () -> Void - let onShowRouting: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -132,9 +124,8 @@ private struct AssistantTurnResponse: View { if let decision = response.decision { RoutingReceiptButton( - decision: decision, - isSelected: isSelected, - action: onShowRouting + turn: turn, + decision: decision ) } } @@ -144,21 +135,21 @@ private struct AssistantTurnResponse: View { } private struct RoutingReceiptButton: View { + let turn: ChatTurn let decision: RoutingDecision - let isSelected: Bool - let action: () -> Void + @State private var showsRoutingDetails = false var body: some View { - Button(action: action) { + Button { + showsRoutingDetails.toggle() + } label: { HStack(spacing: 6) { Image(systemName: decision.route.symbolName) .foregroundStyle(decision.route.accentColor) Text(decision.routeSummary) .fontWeight(.semibold) - Text("Routing details") - .foregroundStyle(ChatWorkspaceChrome.secondaryText) - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) + Image(systemName: "info.circle") + .font(.caption.weight(.semibold)) .foregroundStyle(ChatWorkspaceChrome.tertiaryText) } .font(.caption) @@ -166,9 +157,21 @@ private struct RoutingReceiptButton: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .foregroundStyle(isSelected ? decision.route.accentColor : .primary) + .foregroundStyle(decision.route.accentColor) .accessibilityLabel("\(decision.routeSummary). Show routing details.") - .help("Show this turn in the routing inspector") + .help("Show routing details") + .onHover { hovering in + if hovering { + showsRoutingDetails = true + } + } + .popover(isPresented: $showsRoutingDetails, arrowEdge: .bottom) { + RoutingOutputsPanel( + turn: turn, + onClose: { showsRoutingDetails = false } + ) + .frame(width: 400, height: 560) + } } } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift index 14092848..bcff9460 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatWorkspaceChrome.swift @@ -13,16 +13,12 @@ enum ChatWorkspaceChrome { static let sidebarMinimumWidth: CGFloat = 210 static let sidebarWidth: CGFloat = 220 static let sidebarMaximumWidth: CGFloat = 260 - static let inspectorMinimumWidth: CGFloat = 260 - static let inspectorWidth: CGFloat = 296 - static let inspectorMaximumWidth: CGFloat = 340 static let conversationWidth: CGFloat = 760 static let composerWidth: CGFloat = 780 static let initialWindowWidth: CGFloat = 1_180 static let initialWindowHeight: CGFloat = 720 static let minimumWindowWidth: CGFloat = 900 static let minimumWindowHeight: CGFloat = 580 - static let showsInspectorByDefault = false } public enum ChatRouteFilter: String, CaseIterable, Identifiable { diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift index 0270d9fc..a3ff6101 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift @@ -3,10 +3,8 @@ import SwiftUI public struct WayfinderChatWindow: View { @EnvironmentObject private var appState: AppState @State private var selectedTurnID: UUID? - @State private var routeFilter: ChatRouteFilter = .all @State private var searchText = "" @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var showsInspector = ChatWorkspaceChrome.showsInspectorByDefault @State private var followsLatestTurn = true @State private var searchFocusRequest = 0 @@ -14,22 +12,19 @@ public struct WayfinderChatWindow: View { public var body: some View { let turns = ChatTurn.make(from: appState.chatMessages) - let workspace = ChatWorkspaceContent( - turns: turns, - routeFilter: routeFilter, - searchText: searchText - ) - let visibleTurns = workspace.navigatorTurns - NavigationSplitView(columnVisibility: $columnVisibility) { ChatSidebarView( - turns: turns, - visibleTurns: visibleTurns, - selectedTurnID: $selectedTurnID, - routeFilter: $routeFilter, + conversations: appState.chatConversations, + activeConversationID: appState.activeChatConversationID, searchText: $searchText, searchFocusRequest: searchFocusRequest, - onNewChat: appState.clearChat + isSending: appState.isSendingMessage, + onNewChat: appState.startNewChat, + onSelectConversation: { id in + appState.selectChatConversation(id) + selectedTurnID = nil + followsLatestTurn = true + } ) .navigationSplitViewColumnWidth( min: ChatWorkspaceChrome.sidebarMinimumWidth, @@ -41,25 +36,19 @@ public struct WayfinderChatWindow: View { ChatToolbar( title: chatTitle(in: turns), showsSidebar: showsSidebar, - showsInspector: showsInspector, onToggleSidebar: toggleSidebar, onFocusSearch: focusSearch, - onToggleInspector: { showsInspector.toggle() }, canRetry: appState.canRetryChat, canClear: appState.canClearChat, onRetry: retryLastTurn, - onClear: appState.clearChat + onClear: appState.startNewChat ) Divider() ChatConversationView( - turns: workspace.transcriptTurns, + turns: turns, selectedTurnID: $selectedTurnID, canRetry: appState.canRetryChat, - onRetry: retryLastTurn, - onOpenRouting: { turn in - selectedTurnID = turn.id - showsInspector = true - } + onRetry: retryLastTurn ) ChatComposerView( draft: $appState.chatDraft, @@ -75,17 +64,6 @@ public struct WayfinderChatWindow: View { ) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .inspector(isPresented: $showsInspector) { - RoutingOutputsPanel( - turn: selectedTurn(in: turns), - onClose: { showsInspector = false } - ) - .inspectorColumnWidth( - min: ChatWorkspaceChrome.inspectorMinimumWidth, - ideal: ChatWorkspaceChrome.inspectorWidth, - max: ChatWorkspaceChrome.inspectorMaximumWidth - ) - } } .navigationSplitViewStyle(.prominentDetail) .frame( @@ -94,7 +72,7 @@ public struct WayfinderChatWindow: View { ) .background(ChatWorkspaceChrome.canvas) .onAppear { - selectedTurnID = selectedTurnID ?? visibleTurns.last?.id + selectedTurnID = selectedTurnID ?? turns.last?.id } .onChange(of: turns.map(\.id)) { if turns.isEmpty { @@ -121,7 +99,11 @@ public struct WayfinderChatWindow: View { } private func toggleSidebar() { - columnVisibility = showsSidebar ? .detailOnly : .all + if showsSidebar { + columnVisibility = .detailOnly + } else { + columnVisibility = .all + } } private func focusSearch() { @@ -131,7 +113,6 @@ public struct WayfinderChatWindow: View { private func resetWorkspace() { selectedTurnID = nil - routeFilter = .all searchText = "" followsLatestTurn = true } @@ -149,21 +130,13 @@ public struct WayfinderChatWindow: View { return firstPrompt } - private func selectedTurn(in turns: [ChatTurn]) -> ChatTurn? { - guard let selectedTurnID else { - return nil - } - return turns.first { $0.id == selectedTurnID } - } } private struct ChatToolbar: View { let title: String let showsSidebar: Bool - let showsInspector: Bool let onToggleSidebar: () -> Void let onFocusSearch: () -> Void - let onToggleInspector: () -> Void let canRetry: Bool let canClear: Bool let onRetry: () -> Void @@ -197,13 +170,6 @@ private struct ChatToolbar: View { .keyboardShortcut("r", modifiers: .command) .help("Retry the last response") } - Button(action: onToggleInspector) { - Image(systemName: "sidebar.right") - .symbolVariant(showsInspector ? .fill : .none) - } - .accessibilityLabel(showsInspector ? "Hide routing inspector" : "Show routing inspector") - .keyboardShortcut("i", modifiers: [.command, .control]) - .help(showsInspector ? "Hide routing inspector" : "Show routing inspector") Button(action: onClear) { Image(systemName: "square.and.pencil") } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/AccountsSettingsView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/AccountsSettingsView.swift index 24392e09..4a6b7cb8 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/AccountsSettingsView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/AccountsSettingsView.swift @@ -3,24 +3,28 @@ import SwiftUI public struct AccountsSettingsView: View { @ObservedObject private var accountState: CodexAccountSettingsState + private let embedded: Bool @State private var confirmSignOut = false - public init(accountState: CodexAccountSettingsState) { + public init(accountState: CodexAccountSettingsState, embedded: Bool = false) { self.accountState = accountState + self.embedded = embedded } public var body: some View { VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 6) { - Text("Accounts") - .font(.title3.weight(.semibold)) - Text("Connect services that use an account rather than a provider API key.") - .font(.callout) - .foregroundStyle(.secondary) + if !embedded { + VStack(alignment: .leading, spacing: 6) { + Text("Accounts") + .font(.title3.weight(.semibold)) + Text("Connect services that use an account rather than a provider API key.") + .font(.callout) + .foregroundStyle(.secondary) + } } Form { - Section("ChatGPT") { + Section(embedded ? "Account" : "ChatGPT") { accountContent } @@ -34,9 +38,9 @@ public struct AccountsSettingsView: View { } .formStyle(.grouped) } - .padding(.horizontal, 28) - .padding(.top, 24) - .padding(.bottom, 16) + .padding(.horizontal, embedded ? 0 : 28) + .padding(.top, embedded ? 0 : 24) + .padding(.bottom, embedded ? 0 : 16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { if accountState.state == .checking { @@ -67,6 +71,21 @@ public struct AccountsSettingsView: View { symbol: "person.crop.circle.badge.clock", showsProgress: true ) + case .needsConfiguration: + VStack(alignment: .leading, spacing: 12) { + statusRow( + title: "Connect ChatGPT", + detail: "Wayfinder will add ChatGPT as an available destination without changing Automatic routing.", + symbol: "person.crop.circle.badge.plus" + ) + HStack(spacing: 8) { + Button("Connect ChatGPT") { configureAndBeginBrowserLogin() } + .buttonStyle(.borderedProminent) + .disabled(accountState.isPerformingAction) + actionProgress + Spacer() + } + } case .signedOut: signedOutContent case .awaitingBrowser(let login): @@ -322,6 +341,14 @@ public struct AccountsSettingsView: View { } } + private func configureAndBeginBrowserLogin() { + Task { + if let url = await accountState.configureAndBeginLogin() { + NSWorkspace.shared.open(url) + } + } + } + private func connectedDetail(_ profile: CodexAccountProfile) -> String { if let plan = profile.plan { return "Signed in with a \(plan) ChatGPT account." diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/ConnectionsSettingsView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/ConnectionsSettingsView.swift new file mode 100644 index 00000000..53774732 --- /dev/null +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/ConnectionsSettingsView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +public enum ConnectionKind: String, CaseIterable, Identifiable, Sendable { + case chatGPT = "ChatGPT" + case anthropic = "Anthropic API" + case openAI = "OpenAI API" + case googleGemini = "Google Gemini API" + case ollama = "Ollama" + case lmStudio = "LM Studio" + case custom = "Custom API" + + public var id: String { rawValue } + + var provider: ProviderKind? { + switch self { + case .chatGPT: nil + case .anthropic: .anthropic + case .openAI: .openAI + case .googleGemini: .googleGemini + case .ollama: .ollama + case .lmStudio: .lmStudio + case .custom: .custom + } + } +} + +public struct ConnectionsSettingsView: View { + @Binding private var selectedConnection: ConnectionKind + @ObservedObject private var accountState: CodexAccountSettingsState + + public init( + selectedConnection: Binding, + accountState: CodexAccountSettingsState + ) { + self._selectedConnection = selectedConnection + self.accountState = accountState + } + + public var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Connections") + .font(.title3.weight(.semibold)) + Text("Connect accounts, APIs, and local model servers that Wayfinder can use.") + .font(.callout) + .foregroundStyle(.secondary) + } + + HStack { + Text("Service") + .font(.callout.weight(.medium)) + Spacer() + Picker("Service", selection: $selectedConnection) { + ForEach(ConnectionKind.allCases) { connection in + Text(connection.rawValue).tag(connection) + } + } + .labelsHidden() + .frame(width: 230) + } + + Divider() + } + .padding(.horizontal, 28) + .padding(.top, 24) + + if selectedConnection == .chatGPT { + AccountsSettingsView(accountState: accountState, embedded: true) + .padding(.horizontal, 28) + .padding(.bottom, 16) + } else if let provider = selectedConnection.provider { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + ProviderFormView(provider: provider) + KeychainInfoBox() + } + .padding(.horizontal, 28) + .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/GatewaySettingsView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/GatewaySettingsView.swift index 4996ad84..0652b3eb 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/GatewaySettingsView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/GatewaySettingsView.swift @@ -4,6 +4,7 @@ import SwiftUI public struct GatewaySettingsView: View { @State private var isRestarting = false @State private var isRefreshing = false + @State private var isShowingIntegrationDetails = false @State private var message: GatewayActionMessage? @State private var status = GatewayServiceStatus( installed: false, @@ -30,27 +31,40 @@ public struct GatewaySettingsView: View { VStack(alignment: .leading, spacing: 6) { Text("Gateway") .font(.title3.weight(.semibold)) - Text("Apps connect to this local router. Wayfinder routes each request to a configured endpoint.") + Text("The local service that routes requests between your connections.") .font(.callout) .foregroundStyle(.secondary) } Form { - Section("Status and connections") { - gatewayStatusSection - } + Section("Service") { + GatewayValueRow( + title: "Status", + value: serviceSummary, + symbolName: statusSymbolName, + tint: statusTint + ) + + HStack(spacing: 8) { + Button(isRestarting ? "Restarting…" : "Restart") { restartGateway() } + .disabled(isRestarting || isRefreshing) + Button(isRefreshing ? "Refreshing…" : "Refresh") { refreshStatus() } + .disabled(isRefreshing || isRestarting) + Spacer() + Button("Run Setup Assistant…") { + NotificationCenter.default.post(name: .wayfinderRunSetupAssistant, object: nil) + } + } - Section("Setup") { - Button("Run Setup Assistant…") { - NotificationCenter.default.post(name: .wayfinderRunSetupAssistant, object: nil) + if message != nil { + InlineGatewayMessage(message: message) } - Text("Recheck tools, routing configuration, credentials, and the gateway service.") - .font(.caption) - .foregroundStyle(.secondary) } - Section("Use with apps") { - GatewayExplainerSection() + Section { + DisclosureGroup("Integration details", isExpanded: $isShowingIntegrationDetails) { + integrationDetails + } } } .formStyle(.grouped) @@ -64,16 +78,8 @@ public struct GatewaySettingsView: View { } } - private var gatewayStatusSection: some View { + private var integrationDetails: some View { VStack(spacing: 0) { - GatewayValueRow( - title: "Service", - value: status.statusSummary, - detail: status.health?.detailSummary ?? GatewayServiceController.launchdLabel, - symbolName: statusSymbolName, - tint: statusTint - ) - GatewayValueRow( title: "Local Router", value: status.launchConfiguration.localRouterURL, @@ -110,8 +116,6 @@ public struct GatewaySettingsView: View { onCopy: showCopiedRouteName ) - GatewayDiagnosticsDivider() - GatewayValueRow( title: "Health Check", value: status.launchConfiguration.healthURLString, @@ -130,53 +134,25 @@ public struct GatewaySettingsView: View { onCopy: showCopiedMessage ) - Divider() - - HStack(spacing: 8) { - Button { - restartGateway() - } label: { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - .opacity(isRestarting ? 1 : 0) - .frame(width: 14, height: 14) - Text("Restart Gateway") - } - .frame(minWidth: 132) - } - .disabled(isRestarting || isRefreshing) - - Button { - refreshStatus() - } label: { - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - .opacity(isRefreshing ? 1 : 0) - .frame(width: 14, height: 14) - Text("Refresh Status") - } - .frame(minWidth: 124) - } - .disabled(isRefreshing || isRestarting) - - Button { - revealConfig() - } label: { - Text("Show Config") - } - + HStack { + Button("Show Config") { revealConfig() } Spacer() } .padding(.horizontal, 12) - .frame(height: 46) + .padding(.vertical, 8) + } + } - Divider() - InlineGatewayMessage(message: message) - .padding(.horizontal, 12) - .frame(height: 38) + private var serviceSummary: String { + if let health = status.health { + let count = health.models.count + let models = "\(count) model\(count == 1 ? "" : "s")" + if health.offline { return "Offline · \(models)" } + if health.status == "ok" { return "Running · \(models)" } + return "Needs attention · \(models)" } + if !status.installed { return "Not installed" } + return status.loaded ? "Starting…" : "Stopped" } private var configPath: String { diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/KeysSettingsView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/KeysSettingsView.swift index cf78ded9..ff55e072 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/KeysSettingsView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/KeysSettingsView.swift @@ -13,13 +13,28 @@ public struct KeysSettingsView: View { VStack(alignment: .leading, spacing: 6) { Text("Keys") .font(.title3.weight(.semibold)) - Text("Provider credentials are read by the gateway.") + Text("API keys for provider routes. Account sign-in is managed separately.") .font(.callout) .foregroundStyle(.secondary) } ProviderTabRow(selected: $selectedProvider) + if selectedProvider == .openAI { + Label { + Text("ChatGPT subscription access is connected under Accounts and does not use an OpenAI API key.") + } icon: { + Image(systemName: "person.crop.circle") + } + .font(.callout) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(WayfinderTheme.panel.opacity(0.46)) + .accessibilityElement(children: .combine) + } + ProviderFormView(provider: selectedProvider) KeychainInfoBox() diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/ProviderFormView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/ProviderFormView.swift index ead1fc40..54667ed3 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/ProviderFormView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/ProviderFormView.swift @@ -48,7 +48,7 @@ public struct ProviderFormView: View { ExistingKeyRow(title: "Provider", value: detail.providerName, symbolName: "building.2") ExistingKeyRow(title: "Models", value: detail.modelSummary, symbolName: "cpu") ProviderModelListRow(models: detail.models) - ExistingKeyRow(title: "Key env var", value: detail.keyEnvironmentVariable ?? "none", symbolName: "terminal") + ExistingKeyRow(title: "API key variable", value: detail.keyEnvironmentVariable ?? "none", symbolName: "terminal") ExistingKeyRow(title: "Status", value: effectiveStatus.title, symbolName: effectiveStatus.symbolName, status: effectiveStatus) } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/SettingsWindow.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/SettingsWindow.swift index b340e0c8..c0a2c234 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/SettingsWindow.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Settings/SettingsWindow.swift @@ -3,7 +3,7 @@ import SwiftUI public final class SettingsWindowNavigation: ObservableObject { @Published public var selectedSection: SettingsSection - public init(selectedSection: SettingsSection = .gateway) { + public init(selectedSection: SettingsSection = .connections) { self.selectedSection = selectedSection } @@ -12,14 +12,14 @@ public final class SettingsWindowNavigation: ObservableObject { } public static func section(from notification: Notification) -> SettingsSection { - notification.object as? SettingsSection ?? .gateway + notification.object as? SettingsSection ?? .connections } } public struct WayfinderSettingsWindow: View { @ObservedObject private var navigation: SettingsWindowNavigation @ObservedObject private var appState: AppState - @State private var selectedProvider: ProviderKind = .anthropic + @State private var selectedConnection: ConnectionKind = .chatGPT @StateObject private var codexAccountState: CodexAccountSettingsState public init( @@ -57,32 +57,77 @@ public struct WayfinderSettingsWindow: View { GatewaySettingsView() case .routing: RoutingSettingsView(appState: appState) - case .accounts: - AccountsSettingsView(accountState: codexAccountState) - case .keys: - KeysSettingsView(selectedProvider: $selectedProvider) - case .privacy: - PrivacySettingsView() - case .help: - HelpSettingsView() + case .connections: + ConnectionsSettingsView( + selectedConnection: $selectedConnection, + accountState: codexAccountState + ) case .about: AboutSettingsView() } } } -private struct AboutSettingsView: View { - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Wayfinder") - .font(.title3.weight(.semibold)) - Text("A native menu-bar client for the local Wayfinder gateway.") - .font(.callout) - .foregroundStyle(.secondary) - Spacer() +public struct AboutSettingsView: View { + @State private var showsPrivacy = false + @State private var showsHelp = false + + public init() {} + + public var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Wayfinder") + .font(.title3.weight(.semibold)) + Text("Local-first model routing and chat for this Mac.") + .font(.callout) + .foregroundStyle(.secondary) + } + + VStack(spacing: 0) { + SettingsInfoRow( + title: "Version", + value: versionLabel, + symbolName: "app.badge" + ) + SettingsInfoRow( + title: "Gateway", + value: "Runs locally and chooses from your configured connections.", + symbolName: "server.rack" + ) + } + .background(WayfinderTheme.panel.opacity(0.62)) + + DisclosureGroup("Privacy", isExpanded: $showsPrivacy) { + VStack(spacing: 0) { + SettingsInfoRow(title: "Routing", value: "Routing decisions are computed on this Mac.", symbolName: "point.topleft.down.curvedto.point.bottomright.up") + SettingsInfoRow(title: "Prompts", value: "Prompts go only to the connection selected by the gateway.", symbolName: "arrow.triangle.branch") + SettingsInfoRow(title: "Credentials", value: "API keys are stored in the macOS Keychain.", symbolName: "key") + SettingsInfoRow(title: "Offline", value: "Offline mode is the only mode that guarantees nothing leaves this Mac.", symbolName: "wifi.slash") + } + .padding(.top, 8) + } + + DisclosureGroup("Help", isExpanded: $showsHelp) { + VStack(spacing: 0) { + SettingsInfoRow(title: "Connect apps", value: "Copy compatible URLs from Gateway → Integration details.", symbolName: "link") + SettingsInfoRow(title: "Automatic", value: "Use the auto model route to let Wayfinder choose.", symbolName: "arrow.triangle.branch") + SettingsInfoRow(title: "Pinned routes", value: "Choose prefer-local, prefer-hosted, or a named connection when needed.", symbolName: "mappin.and.ellipse") + SettingsInfoRow(title: "Diagnostics", value: "Gateway contains restart, status and configuration tools.", symbolName: "stethoscope") + } + .padding(.top, 8) + } + + Spacer(minLength: 0) } .padding(.horizontal, 28) .padding(.vertical, 24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + + private var versionLabel: String { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Development" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + return build.map { "\(version) (\($0))" } ?? version + } } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift b/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift index f8180362..250ac901 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift @@ -5,6 +5,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private let appState: AppState private let featurePolicy: ReleaseFeaturePolicy private let openChatOnLaunch: Bool + private let gatewayReplacementReconciler: GatewayReplacementReconciler private var statusItemController: StatusItemController? private var chatFeatureCoordinator: ChatFeatureCoordinator? private var settingsWindowController: SettingsWindowController? @@ -16,16 +17,29 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { public init( client: any WayfinderClient, featurePolicy: ReleaseFeaturePolicy = .current, - openChatOnLaunch: Bool = false + openChatOnLaunch: Bool = false, + gatewayReplacementReconciler: GatewayReplacementReconciler = GatewayReplacementReconciler() ) { - self.appState = AppState(client: client) + self.appState = AppState( + client: client, + chatConversationStore: .applicationSupport() + ) self.featurePolicy = featurePolicy self.openChatOnLaunch = openChatOnLaunch + self.gatewayReplacementReconciler = gatewayReplacementReconciler super.init() } public func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) + Task { @MainActor [weak self] in + guard let self else { return } + _ = await gatewayReplacementReconciler.reconcile() + finishLaunching() + } + } + + private func finishLaunching() { let chatFeatureCoordinator = ChatFeatureCoordinator( policy: featurePolicy, appState: appState @@ -76,7 +90,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { false } - private func showSettingsWindow(section: SettingsSection = .gateway) { + private func showSettingsWindow(section: SettingsSection = .connections) { settingsWindowController?.show(section: section) } } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Windowing/SettingsWindowController.swift b/macos/WayfinderMac/Sources/WayfinderMac/Windowing/SettingsWindowController.swift index 4b5a1fe7..3be0960e 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/Windowing/SettingsWindowController.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/Windowing/SettingsWindowController.swift @@ -26,7 +26,7 @@ final class SettingsWindowController { self.navigation = navigation } - func show(section: SettingsSection = .gateway) { + func show(section: SettingsSection = .connections) { navigation.select(section) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatDeliveryTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatDeliveryTests.swift index 9c58e209..93b79267 100644 --- a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatDeliveryTests.swift +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatDeliveryTests.swift @@ -167,14 +167,14 @@ final class ChatDeliveryTests: XCTestCase { WayfinderClientError.chatAccountNotReady, destination: destination ), - .accounts + .connections ) XCTAssertEqual( AppState.chatRecoverySettingsSection( WayfinderClientError.chatAccountNotReady, destination: .automatic ), - .accounts + .connections ) XCTAssertEqual( AppState.chatRecoverySettingsSection( diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift index 51c89fdd..b7d7fd29 100644 --- a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift @@ -26,6 +26,56 @@ final class ChatStateTests: XCTestCase { XCTAssertEqual(state.routingStats.totalTurns, 0) } + func testNewChatRetainsPreviousConversationForSelection() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = ChatConversationStore( + fileURL: directory.appendingPathComponent("chat-history.json") + ) + let previous = ChatConversation(messages: [ + ChatMessage(role: .user, text: "Keep this conversation"), + ChatMessage(role: .assistant, text: "It will remain available.") + ]) + store.save([previous]) + defer { try? FileManager.default.removeItem(at: directory) } + + let state = AppState( + client: MockWayfinderClient(), + chatConversationStore: store + ) + + XCTAssertEqual(state.chatMessages, previous.messages) + state.startNewChat() + XCTAssertTrue(state.chatMessages.isEmpty) + XCTAssertEqual(state.chatConversations.map(\.id), [previous.id]) + + state.selectChatConversation(previous.id) + XCTAssertEqual(state.activeChatConversationID, previous.id) + XCTAssertEqual(state.chatMessages, previous.messages) + } + + func testConversationStorePersistsNewestFirst() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = ChatConversationStore( + fileURL: directory.appendingPathComponent("chat-history.json") + ) + defer { try? FileManager.default.removeItem(at: directory) } + let older = ChatConversation( + messages: [ChatMessage(role: .user, text: "Older")], + updatedAt: Date(timeIntervalSince1970: 1) + ) + let newer = ChatConversation( + messages: [ChatMessage(role: .user, text: "Newer")], + updatedAt: Date(timeIntervalSince1970: 2) + ) + + store.save([older, newer]) + + XCTAssertEqual(store.load().map(\.id), [newer.id, older.id]) + XCTAssertEqual(store.load().map(\.title), ["Newer", "Older"]) + } + func testFailedResponseRemainsDistinctFromPendingTurn() { let prompt = ChatMessage(role: .user, text: "Route this") let failure = ChatMessage(role: .assistant, text: "Gateway unavailable", state: .failed) @@ -185,20 +235,11 @@ final class ChatStateTests: XCTestCase { func testChatWorkspaceSizingKeepsTheConversationPrimary() { let defaultConversationWidth = ChatWorkspaceChrome.initialWindowWidth - ChatWorkspaceChrome.sidebarWidth - let expandedInspectorConversationWidth = ChatWorkspaceChrome.initialWindowWidth - - ChatWorkspaceChrome.sidebarWidth - - ChatWorkspaceChrome.inspectorWidth let minimumConversationWidth = ChatWorkspaceChrome.minimumWindowWidth - ChatWorkspaceChrome.sidebarMinimumWidth - XCTAssertFalse(ChatWorkspaceChrome.showsInspectorByDefault) XCTAssertGreaterThanOrEqual(defaultConversationWidth, ChatWorkspaceChrome.conversationWidth) - XCTAssertGreaterThanOrEqual(expandedInspectorConversationWidth, 640) XCTAssertGreaterThanOrEqual(minimumConversationWidth, 640) - XCTAssertLessThan( - ChatWorkspaceChrome.sidebarWidth + ChatWorkspaceChrome.inspectorWidth, - ChatWorkspaceChrome.initialWindowWidth / 2 - ) } func testStreamingOnlyFollowsAnExplicitlySelectedLatestTurn() { diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/CodexAccountSettingsStateTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/CodexAccountSettingsStateTests.swift index ec0e973d..592f9df5 100644 --- a/macos/WayfinderMac/Tests/WayfinderMacTests/CodexAccountSettingsStateTests.swift +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/CodexAccountSettingsStateTests.swift @@ -3,6 +3,29 @@ import XCTest @testable import WayfinderMacCore final class CodexAccountSettingsStateTests: XCTestCase { + @MainActor + func testMissingRouteBecomesOneClickConfigurationThenBrowserLogin() async { + let login = CodexPendingLogin( + id: "configured-login", + flow: .browser, + url: URL(string: "https://auth.openai.com/start")! + ) + let client = MissingRouteCodexClient(login: login) + let configurator = RecordingChatGPTConfigurator() + let state = CodexAccountSettingsState( + client: client, + configurator: configurator, + automaticallyPollLogin: false + ) + + await state.refresh() + XCTAssertEqual(state.state, .needsConfiguration) + let authorizationURL = await state.configureAndBeginLogin() + XCTAssertEqual(authorizationURL, login.url) + XCTAssertEqual(state.state, .awaitingBrowser(login)) + let configurationCalls = await configurator.callCount() + XCTAssertEqual(configurationCalls, 1) + } @MainActor func testBrowserLoginCanBeCancelledWithoutExposingAnythingButLoginID() async { let login = CodexPendingLogin( @@ -113,6 +136,22 @@ final class CodexAccountSettingsStateTests: XCTestCase { } } +private actor RecordingChatGPTConfigurator: ChatGPTProviderConfigurator { + private var calls = 0 + func configure() async throws { calls += 1 } + func callCount() -> Int { calls } +} + +private actor MissingRouteCodexClient: CodexAccountClient { + let login: CodexPendingLogin + init(login: CodexPendingLogin) { self.login = login } + func account() async throws -> CodexAccountSnapshot { throw CodexAccountClientError.gatewayStatus(404) } + func models() async throws -> CodexModelsResponse { CodexModelsResponse(models: []) } + func beginLogin(flow: CodexLoginFlow) async throws -> CodexAccountSnapshot { .awaitingBrowser(login) } + func cancelLogin(id: String) async throws -> CodexAccountSnapshot { .signedOut } + func logout() async throws -> CodexAccountSnapshot { .signedOut } +} + private actor ScriptedCodexAccountClient: CodexAccountClient { private var accountResponse: CodexAccountSnapshot private let modelsResponse: CodexModelsResponse? diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/GatewayReplacementReconcilerTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/GatewayReplacementReconcilerTests.swift new file mode 100644 index 00000000..c30d58ce --- /dev/null +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/GatewayReplacementReconcilerTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +@testable import WayfinderMacCore + +final class GatewayReplacementReconcilerTests: XCTestCase { + private actor Recorder { + var restarts: [URL] = [] + var markers: [String] = [] + + func restart(_ gateway: URL) { restarts.append(gateway) } + func write(_ marker: String) { markers.append(marker) } + func snapshot() -> (restarts: [URL], markers: [String]) { (restarts, markers) } + } + + func testChangedInstallationRestartsMatchingLoadedBundledGatewayAndPersistsMarker() async { + let gateway = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/Helpers/WayfinderGateway.app/Contents/MacOS/wayfinder-router") + let recorder = Recorder() + let reconciler = makeReconciler( + gateway: gateway, + storedMarker: "old", + fingerprint: "new", + recorder: recorder + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .restarted) + XCTAssertEqual(snapshot.restarts, [gateway]) + XCTAssertEqual(snapshot.markers, ["new"]) + } + + func testUnchangedInstallationDoesNotQueryOrRestartService() async { + let gateway = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/MacOS/wayfinder-router") + let recorder = Recorder() + let reconciler = GatewayReplacementReconciler( + locateGateway: { gateway }, + statusQuery: { _ in XCTFail("Unchanged installation must not query service"); return Self.status(gateway: gateway) }, + restart: { _ in XCTFail("Unchanged installation must not restart") }, + fingerprintProvider: { _ in "same" }, + markerReader: { "same" }, + markerWriter: { _ in XCTFail("Unchanged installation must not rewrite marker") } + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .unchanged) + XCTAssertTrue(snapshot.restarts.isEmpty) + } + + func testMismatchedStoppedOrUninstalledServiceDefersWithoutMarker() async { + let expected = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/MacOS/wayfinder-router") + let other = URL(fileURLWithPath: "/opt/homebrew/bin/wayfinder-router") + for status in [ + Self.status(gateway: other), + Self.status(gateway: expected, loaded: false), + Self.status(gateway: expected, installed: false), + ] { + let recorder = Recorder() + let reconciler = GatewayReplacementReconciler( + locateGateway: { expected }, + statusQuery: { _ in status }, + restart: { gateway in await recorder.restart(gateway) }, + fingerprintProvider: { _ in "new" }, + markerReader: { "old" }, + markerWriter: { marker in await recorder.write(marker) } + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .deferred) + XCTAssertTrue(snapshot.restarts.isEmpty) + XCTAssertTrue(snapshot.markers.isEmpty) + } + } + + func testFailedRestartDefersAndDoesNotPersistMarker() async { + let gateway = URL(fileURLWithPath: "/Applications/Wayfinder.app/Contents/MacOS/wayfinder-router") + let recorder = Recorder() + let reconciler = GatewayReplacementReconciler( + locateGateway: { gateway }, + statusQuery: { _ in Self.status(gateway: gateway) }, + restart: { _ in throw GatewayServiceControllerError.restartFailed("expected") }, + fingerprintProvider: { _ in "new" }, + markerReader: { "old" }, + markerWriter: { marker in await recorder.write(marker) } + ) + + let result = await reconciler.reconcile() + let snapshot = await recorder.snapshot() + XCTAssertEqual(result, .deferred) + XCTAssertTrue(snapshot.markers.isEmpty) + } + + private func makeReconciler( + gateway: URL, + storedMarker: String?, + fingerprint: String, + recorder: Recorder + ) -> GatewayReplacementReconciler { + GatewayReplacementReconciler( + locateGateway: { gateway }, + statusQuery: { _ in Self.status(gateway: gateway) }, + restart: { value in await recorder.restart(value) }, + fingerprintProvider: { _ in fingerprint }, + markerReader: { storedMarker }, + markerWriter: { marker in await recorder.write(marker) } + ) + } + + private static func status( + gateway: URL, + installed: Bool = true, + loaded: Bool = true + ) -> GatewayServiceStatus { + GatewayServiceStatus( + installed: installed, + loaded: loaded, + launchConfiguration: GatewayLaunchConfiguration( + host: "127.0.0.1", + port: 8088, + configPath: "/tmp/config.toml", + executablePath: gateway.path + ), + health: nil + ) + } +} diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/SettingsSectionTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/SettingsSectionTests.swift index 495ea0e5..686984e8 100644 --- a/macos/WayfinderMac/Tests/WayfinderMacTests/SettingsSectionTests.swift +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/SettingsSectionTests.swift @@ -2,56 +2,47 @@ import XCTest @testable import WayfinderMacCore final class SettingsSectionTests: XCTestCase { - func testHelpIsSeparateFromAboutAndOrderedBeforeIt() { - let sections = SettingsSection.allCases - - XCTAssertTrue(sections.contains(.help)) - XCTAssertTrue(sections.contains(.about)) - XCTAssertNotEqual(SettingsSection.help.rawValue, SettingsSection.about.rawValue) - XCTAssertLessThan( - sections.firstIndex(of: .help) ?? sections.endIndex, - sections.firstIndex(of: .about) ?? sections.endIndex - ) + func testOpenAIPlatformKeysRemainDistinctFromChatGPTAccountAccess() { + XCTAssertEqual(ProviderKind.openAI.rawValue, "OpenAI API") + XCTAssertEqual(ProviderKind.openAI.credentialDetail.displayName, "OpenAI Platform API") + XCTAssertEqual(CredentialStatus.keyMissing.title, "Not connected") } - func testOnlyShippedSettingsSectionsAreListed() { XCTAssertEqual( SettingsSection.allCases, - [.gateway, .routing, .accounts, .keys, .privacy, .help, .about] + [.connections, .gateway, .routing, .about] ) } - func testAccountsIsSeparateFromKeys() { - XCTAssertEqual(SettingsSection.accounts.rawValue, "Accounts") - XCTAssertEqual(SettingsSection.accounts.symbolName, "person.crop.circle") - XCTAssertLessThan( - SettingsSection.allCases.firstIndex(of: .accounts)!, - SettingsSection.allCases.firstIndex(of: .keys)! - ) + func testAccountsAndKeysAreConsolidatedAsConnections() { + XCTAssertEqual(SettingsSection.connections.rawValue, "Connections") + XCTAssertEqual(SettingsSection.connections.symbolName, "link") + XCTAssertEqual(ConnectionKind.allCases.first, .chatGPT) + XCTAssertEqual(ConnectionKind.openAI.provider, .openAI) } - func testSettingsNotificationDeepLinksToAccountsAndDefaultsToGateway() { + func testSettingsNotificationDeepLinksToConnectionsAndDefaultsToConnections() { let accounts = Notification( name: .wayfinderOpenSettings, - object: SettingsSection.accounts + object: SettingsSection.connections ) - XCTAssertEqual(SettingsWindowNavigation.section(from: accounts), .accounts) + XCTAssertEqual(SettingsWindowNavigation.section(from: accounts), .connections) XCTAssertEqual( SettingsWindowNavigation.section( from: Notification(name: .wayfinderOpenSettings) ), - .gateway + .connections ) let navigation = SettingsWindowNavigation() navigation.select(SettingsWindowNavigation.section(from: accounts)) - XCTAssertEqual(navigation.selectedSection, .accounts) + XCTAssertEqual(navigation.selectedSection, .connections) } - func testHelpSectionUsesExpectedLabelAndSymbol() { - XCTAssertEqual(SettingsSection.help.rawValue, "Help") - XCTAssertEqual(SettingsSection.help.symbolName, "questionmark.circle") + func testAboutConsolidatesLegacyHelpAndPrivacyNavigation() throws { XCTAssertEqual(SettingsSection.about.rawValue, "About") XCTAssertEqual(SettingsSection.about.symbolName, "info.circle") + XCTAssertEqual(try JSONDecoder().decode(SettingsSection.self, from: Data(#""Help""#.utf8)), .about) + XCTAssertEqual(try JSONDecoder().decode(SettingsSection.self, from: Data(#""Privacy""#.utf8)), .about) } } diff --git a/rust/crates/wayfinder-cli/src/app_setup_command.rs b/rust/crates/wayfinder-cli/src/app_setup_command.rs index 75af9a68..cc3d1799 100644 --- a/rust/crates/wayfinder-cli/src/app_setup_command.rs +++ b/rust/crates/wayfinder-cli/src/app_setup_command.rs @@ -9,10 +9,99 @@ use std::io::{self, Write}; use std::path::{Path, PathBuf}; use crate::{EXIT_OK, EXIT_USAGE, write_error, write_output}; +use wayfinder_config::gateway::{ProviderKind, gateway_config_from_toml}; pub(crate) const APP_SETUP_HELP: &str = "usage: wayfinder-router app-setup-init --preset PRESET --path PATH"; +const CHATGPT_MODEL_BLOCK: &str = r#"[gateway.models.chatgpt-sol] +provider = "codex-app-server" +model = "gpt-5.6-sol" +context_window = 1050000 +"#; + +pub(crate) fn run_configure_chatgpt( + arguments: &[String], + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> i32 { + let path = match parse_single_path(arguments, "app-configure-chatgpt") { + Ok(path) => path, + Err(message) => { + write_error(stderr, &format!("wayfinder-router: {message}")); + return EXIT_USAGE; + } + }; + if !is_app_config_path(&path) { + write_error( + stderr, + "wayfinder-router: app configuration path is outside Wayfinder Application Support", + ); + return EXIT_USAGE; + } + match configure_chatgpt(&path) { + Ok(changed) => { + write_output( + stdout, + if changed { + "wayfinder-router: ChatGPT route configured" + } else { + "wayfinder-router: ChatGPT route already configured" + }, + ); + EXIT_OK + } + Err(message) => { + write_error(stderr, &format!("wayfinder-router: {message}")); + EXIT_USAGE + } + } +} + +fn parse_single_path(arguments: &[String], command: &str) -> Result { + if arguments.len() != 2 || arguments[0] != "--path" { + return Err(format!("{command} requires exactly --path PATH")); + } + Ok(PathBuf::from(&arguments[1])) +} + +fn is_app_config_path(path: &Path) -> bool { + let Some(home) = std::env::var_os("HOME") else { + return false; + }; + path == PathBuf::from(home).join("Library/Application Support/Wayfinder/wayfinder-router.toml") +} + +fn configure_chatgpt(path: &Path) -> Result { + let source = fs::read_to_string(path) + .map_err(|error| format!("cannot read {}: {error}", path.display()))?; + let parsed = gateway_config_from_toml(&source, &path.display().to_string()) + .map_err(|error| error.to_string())?; + if parsed + .models + .values() + .any(|model| model.provider == ProviderKind::CodexAppServer) + { + return Ok(false); + } + let separator = if source.is_empty() || source.ends_with("\n\n") { + "" + } else if source.ends_with('\n') { + "\n" + } else { + "\n\n" + }; + let edited = format!("{source}{separator}{CHATGPT_MODEL_BLOCK}"); + gateway_config_from_toml(&edited, &path.display().to_string()) + .map_err(|error| error.to_string())?; + let temporary = path.with_extension("toml.wayfinder-new"); + fs::write(&temporary, edited) + .map_err(|error| format!("cannot stage configuration: {error}"))?; + fs::rename(&temporary, path) + .map_err(|error| format!("cannot replace configuration: {error}"))?; + Ok(true) +} + pub(crate) fn run_app_setup( arguments: &[String], stdout: &mut dyn Write, @@ -212,6 +301,27 @@ mod tests { use wayfinder_config::gateway::gateway_config_from_toml; use wayfinder_config::routing_config_from_toml; + #[test] + fn chatgpt_configuration_preserves_existing_document_and_is_idempotent() -> Result<(), String> { + let root = + std::env::temp_dir().join(format!("wayfinder-chatgpt-config-{}", std::process::id())); + let path = root.join("wayfinder-router.toml"); + fs::create_dir_all(&root).map_err(|error| error.to_string())?; + let original = "# keep this comment\n[gateway.models.apple-local]\nprovider = \"apple-foundation-models\"\nmodel = \"system-default\"\ntier = \"local\"\n"; + fs::write(&path, original).map_err(|error| error.to_string())?; + assert!(configure_chatgpt(&path)?); + let once = fs::read_to_string(&path).map_err(|error| error.to_string())?; + assert!(once.starts_with(original)); + assert!(once.contains("[gateway.models.chatgpt-sol]")); + assert!(!configure_chatgpt(&path)?); + assert_eq!( + fs::read_to_string(&path).map_err(|error| error.to_string())?, + once + ); + fs::remove_dir_all(root).map_err(|error| error.to_string())?; + Ok(()) + } + #[test] fn every_app_preset_is_valid_for_both_config_parsers() -> Result<(), String> { for name in ["apple-local", "hybrid", "local", "openai", "gemini"] { diff --git a/rust/crates/wayfinder-cli/src/lib.rs b/rust/crates/wayfinder-cli/src/lib.rs index cf385ffc..e578f144 100644 --- a/rust/crates/wayfinder-cli/src/lib.rs +++ b/rust/crates/wayfinder-cli/src/lib.rs @@ -148,6 +148,9 @@ pub fn run( "route" => run_route(&arguments[1..], stdin, stdout, stderr), "app-setup-init" => app_setup_command::run_app_setup(&arguments[1..], stdout, stderr), "config" => config_command::run_config(&arguments[1..], stdin, stdout, stderr), + "app-configure-chatgpt" => { + app_setup_command::run_configure_chatgpt(&arguments[1..], stdout, stderr) + } "service" => service_command::run_service(&arguments[1..], stdout, stderr), "capabilities" => run_capabilities(&arguments[1..], stdout, stderr), "apple-foundation-live-smoke" => {