diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatDestinationMentionResolver.swift b/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatDestinationMentionResolver.swift new file mode 100644 index 00000000..506f2f0b --- /dev/null +++ b/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatDestinationMentionResolver.swift @@ -0,0 +1,163 @@ +import Foundation + +public enum ChatDestinationMentionResolution: Equatable, Sendable { + case none + case resolved(destination: ChatDestination, prompt: String) + case ambiguous(token: String, candidates: [ChatDestination]) + case unknown(token: String) +} + +/// Resolves only a leading `@destination` token. +/// +/// Canonical route identifiers always win over friendly aliases. Friendly +/// aliases are accepted only when they identify one destination, so an +/// ambiguous or unknown mention remains ordinary prompt text. +public enum ChatDestinationMentionResolver { + public static func resolve( + draft: String, + destinations: [ChatDestination] + ) -> ChatDestinationMentionResolution { + guard let mention = leadingMention(in: draft), !mention.token.isEmpty else { + return draft.first == "@" ? .unknown(token: "") : .none + } + + let token = mention.token.lowercased() + let candidates = mentionDestinations(from: destinations) + + if token == "local" { + return .resolved(destination: .preferLocal, prompt: mention.prompt) + } + if token == "hosted" { + return .resolved(destination: .preferHosted, prompt: mention.prompt) + } + + let exactMatches = candidates.filter { + $0.routeName?.caseInsensitiveCompare(mention.token) == .orderedSame + } + if exactMatches.count == 1, let destination = exactMatches.first { + return .resolved(destination: destination, prompt: mention.prompt) + } + if exactMatches.count > 1 { + return .ambiguous(token: mention.token, candidates: exactMatches) + } + + if token == "codex" { + let codexMatches = candidates.filter { + $0.isChatGPTAccount && $0.isAvailable + } + if codexMatches.count == 1, let destination = codexMatches.first { + return .resolved(destination: destination, prompt: mention.prompt) + } + if codexMatches.count > 1 { + return .ambiguous(token: mention.token, candidates: codexMatches) + } + return .unknown(token: mention.token) + } + + let normalizedToken = normalizedAlias(mention.token) + let friendlyMatches = candidates.filter { + normalizedAlias($0.title) == normalizedToken + } + if friendlyMatches.count == 1, let destination = friendlyMatches.first { + return .resolved(destination: destination, prompt: mention.prompt) + } + if friendlyMatches.count > 1 { + return .ambiguous(token: mention.token, candidates: friendlyMatches) + } + + return .unknown(token: mention.token) + } + + public static func suggestions( + for draft: String, + destinations: [ChatDestination] + ) -> [ChatDestination] { + guard let query = activeMentionQuery(in: draft) else { + return [] + } + let normalizedQuery = normalizedAlias(query) + return mentionDestinations(from: destinations).filter { destination in + guard !normalizedQuery.isEmpty else { return true } + return searchTerms(for: destination).contains { + $0.hasPrefix(normalizedQuery) + } + } + } + + public static func removingActiveMention(from draft: String) -> String { + guard draft.first == "@" else { return draft } + let tokenEnd = draft.firstIndex(where: \.isWhitespace) ?? draft.endIndex + return String(draft[tokenEnd...]) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + public static func destination( + forGatewayModelValue routeName: String, + destinations: [ChatDestination] + ) -> ChatDestination? { + mentionDestinations(from: destinations).first { + $0.gatewayModelValue.caseInsensitiveCompare(routeName) == .orderedSame + } + } + + private static func leadingMention( + in draft: String + ) -> (token: String, prompt: String)? { + guard draft.first == "@" else { return nil } + let tokenStart = draft.index(after: draft.startIndex) + let tokenEnd = draft[tokenStart...].firstIndex(where: \.isWhitespace) ?? draft.endIndex + let token = String(draft[tokenStart.. String? { + guard draft.first == "@", + !draft.dropFirst().contains(where: \.isWhitespace) else { + return nil + } + return String(draft.dropFirst()) + } + + private static func mentionDestinations( + from destinations: [ChatDestination] + ) -> [ChatDestination] { + var seen = Set() + return ([.preferLocal, .preferHosted] + destinations) + .filter { !$0.isAutomatic } + .filter { seen.insert($0.gatewayModelValue.lowercased()).inserted } + } + + private static func searchTerms(for destination: ChatDestination) -> [String] { + var terms = [ + destination.gatewayModelValue, + destination.title, + destination.defaultTitle, + destination.providerName ?? "", + ].map(normalizedAlias) + if destination == .preferLocal { + terms.append("local") + } + if destination == .preferHosted { + terms.append("hosted") + } + if destination.isChatGPTAccount { + terms.append(contentsOf: ["codex", "chatgpt"]) + } + return terms + } + + private static func normalizedAlias(_ value: String) -> String { + value.lowercased() + .unicodeScalars + .reduce(into: "") { result, scalar in + if CharacterSet.alphanumerics.contains(scalar) { + result.unicodeScalars.append(scalar) + } else if result.last != "-" { + result.append("-") + } + } + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } +} diff --git a/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatModels.swift b/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatModels.swift index 41ee35a7..bfda5181 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatModels.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/Models/ChatModels.swift @@ -17,6 +17,8 @@ public struct ChatMessage: Identifiable, Codable, Equatable, Sendable { public let role: ChatMessageRole public var text: String public var decision: RoutingDecision? + public var requestedGatewayRouteName: String? + public var requestedDestinationTitle: String? public var state: ChatMessageState public var recoverySettingsSection: SettingsSection? public let createdAt: Date @@ -26,6 +28,8 @@ public struct ChatMessage: Identifiable, Codable, Equatable, Sendable { role: ChatMessageRole, text: String, decision: RoutingDecision? = nil, + requestedGatewayRouteName: String? = nil, + requestedDestinationTitle: String? = nil, state: ChatMessageState = .complete, recoverySettingsSection: SettingsSection? = nil, createdAt: Date = Date() @@ -34,6 +38,8 @@ public struct ChatMessage: Identifiable, Codable, Equatable, Sendable { self.role = role self.text = text self.decision = decision + self.requestedGatewayRouteName = requestedGatewayRouteName + self.requestedDestinationTitle = requestedDestinationTitle self.state = state self.recoverySettingsSection = recoverySettingsSection self.createdAt = createdAt @@ -66,6 +72,16 @@ public struct ChatDestination: Identifiable, Hashable, Sendable { title: "Automatic", detail: "Wayfinder chooses" ) + public static let preferLocal = ChatDestination( + routeName: "prefer-local", + title: "Prefer Local", + detail: "Use the best available local route" + ) + public static let preferHosted = ChatDestination( + routeName: "prefer-hosted", + title: "Prefer Hosted", + detail: "Use the best available hosted route" + ) public let routeName: String? public let title: String diff --git a/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift b/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift index f9887590..1aca4902 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift @@ -10,6 +10,7 @@ public final class AppState: ObservableObject { @Published public private(set) var isRefreshingStats = false @Published public var chatDraft = "" @Published public var chatDestination: ChatDestination = .automatic + @Published public private(set) var chatMessageDestinationOverride: ChatDestination? @Published public private(set) var chatDestinations: [ChatDestination] = [.automatic] @Published public private(set) var chatMessages: [ChatMessage] @Published public private(set) var chatConversations: [ChatConversation] @@ -50,9 +51,10 @@ public final class AppState: ObservableObject { } public var canSendMessage: Bool { - !chatDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - && !isSendingMessage - && chatDestination.isAvailable + guard !isSendingMessage, let submission = resolvedChatSubmission else { + return false + } + return submission.destination.isAvailable } public var canClearChat: Bool { @@ -60,9 +62,10 @@ public final class AppState: ObservableObject { } public var canRetryChat: Bool { - !isSendingMessage && chatDestination.isAvailable && chatMessages.last.map { - $0.role == .assistant && ($0.state == .failed || $0.state == .stopped) - } == true + guard !isSendingMessage, let failedTurn = lastRetryableChatTurn else { + return false + } + return retryDestination(for: failedTurn.prompt).isAvailable } public func analyse() { @@ -133,82 +136,26 @@ public final class AppState: ObservableObject { } public func sendChatDraft() { - let input = chatDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard canSendMessage, !input.isEmpty else { + guard !isSendingMessage, + let submission = resolvedChatSubmission, + submission.destination.isAvailable else { return } + submitChatMessage( + prompt: submission.prompt, + destination: submission.destination + ) + } - let requestMessages = Self.chatRequestMessages(from: chatMessages) + [ - ChatRequestMessage(role: "user", content: input) - ] - let destination = chatDestination - let responseID = UUID() - chatDraft = "" - isSendingMessage = true - chatMessages.append(ChatMessage(role: .user, text: input)) - chatMessages.append(ChatMessage( - id: responseID, - role: .assistant, - text: "", - state: .streaming - )) - saveActiveConversation() + public func selectChatMessageDestinationOverride(_ destination: ChatDestination) { + guard !isSendingMessage else { return } + chatMessageDestinationOverride = destination + chatDraft = ChatDestinationMentionResolver.removingActiveMention(from: chatDraft) + } - chatTask = Task { - do { - for try await event in client.streamChat( - messages: requestMessages, - destination: destination - ) { - guard let index = self.chatMessages.firstIndex(where: { $0.id == responseID }) else { - continue - } - switch event { - case let .decision(decision): - self.chatMessages[index].decision = decision - case let .text(fragment): - self.chatMessages[index].text += fragment - case .completed: - self.chatMessages[index].state = self.chatMessages[index].text.isEmpty - ? .failed - : .complete - if self.chatMessages[index].text.isEmpty { - self.chatMessages[index].text = "No model reply was delivered. Check the configured endpoint in Settings." - self.chatMessages[index].recoverySettingsSection = .gateway - } - } - } - if Task.isCancelled { - self.finishStoppedChatMessage(id: responseID) - return - } - if let index = self.chatMessages.firstIndex(where: { $0.id == responseID }), - self.chatMessages[index].state == .streaming { - self.finishFailedChatMessage( - id: responseID, - message: WayfinderClientError.invalidChatStream.localizedDescription - ) - return - } - self.isSendingMessage = false - self.chatTask = nil - self.saveActiveConversation() - self.refreshStats() - } catch { - if Task.isCancelled || error is CancellationError { - self.finishStoppedChatMessage(id: responseID) - } else { - self.finishFailedChatMessage( - id: responseID, - message: Self.chatErrorMessage(error, destination: destination), - recoverySettingsSection: Self.chatRecoverySettingsSection( - error, - destination: destination - ) - ) - } - } - } + public func clearChatMessageDestinationOverride() { + guard !isSendingMessage else { return } + chatMessageDestinationOverride = nil } public func stopChatResponse() { @@ -223,6 +170,7 @@ public final class AppState: ObservableObject { activeChatConversationID = UUID() chatMessages = [] chatDraft = "" + chatMessageDestinationOverride = nil } public func selectChatConversation(_ id: UUID) { @@ -234,21 +182,17 @@ public final class AppState: ObservableObject { activeChatConversationID = conversation.id chatMessages = conversation.messages chatDraft = "" + chatMessageDestinationOverride = nil } public func retryLastChatTurn() { - guard !isSendingMessage, chatDestination.isAvailable, - let responseIndex = chatMessages.lastIndex(where: { - $0.role == .assistant && ($0.state == .failed || $0.state == .stopped) - }), - responseIndex > 0, - chatMessages[responseIndex - 1].role == .user else { + guard !isSendingMessage, let failedTurn = lastRetryableChatTurn else { return } - let prompt = chatMessages[responseIndex - 1].text - chatMessages.removeSubrange((responseIndex - 1)...responseIndex) - chatDraft = prompt - sendChatDraft() + let destination = retryDestination(for: failedTurn.prompt) + guard destination.isAvailable else { return } + chatMessages.removeSubrange(failedTurn.promptIndex...failedTurn.responseIndex) + submitChatMessage(prompt: failedTurn.prompt.text, destination: destination) } static func chatRequestMessages(from messages: [ChatMessage]) -> [ChatRequestMessage] { @@ -338,6 +282,141 @@ public final class AppState: ObservableObject { chatDestinations = destinations } + private var resolvedChatSubmission: ChatSubmission? { + let trimmedDraft = chatDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedDraft.isEmpty else { return nil } + + if let chatMessageDestinationOverride { + return ChatSubmission( + prompt: trimmedDraft, + destination: chatMessageDestinationOverride + ) + } + + switch ChatDestinationMentionResolver.resolve( + draft: trimmedDraft, + destinations: chatDestinations + ) { + case let .resolved(destination, prompt): + guard !prompt.isEmpty else { return nil } + return ChatSubmission(prompt: prompt, destination: destination) + case .none, .ambiguous, .unknown: + return ChatSubmission(prompt: trimmedDraft, destination: chatDestination) + } + } + + private var lastRetryableChatTurn: ( + promptIndex: Int, + responseIndex: Int, + prompt: ChatMessage + )? { + guard let responseIndex = chatMessages.lastIndex(where: { + $0.role == .assistant && ($0.state == .failed || $0.state == .stopped) + }), + responseIndex > 0, + chatMessages[responseIndex - 1].role == .user else { + return nil + } + return (responseIndex - 1, responseIndex, chatMessages[responseIndex - 1]) + } + + private func retryDestination(for prompt: ChatMessage) -> ChatDestination { + guard let routeName = prompt.requestedGatewayRouteName else { + return .automatic + } + if chatDestination.gatewayModelValue.caseInsensitiveCompare(routeName) == .orderedSame { + return chatDestination + } + return ChatDestinationMentionResolver.destination( + forGatewayModelValue: routeName, + destinations: chatDestinations + ) ?? ChatDestination( + routeName: routeName, + title: prompt.requestedDestinationTitle ?? routeName, + detail: "Previously requested route", + isAvailable: false + ) + } + + private func submitChatMessage(prompt: String, destination: ChatDestination) { + let requestMessages = Self.chatRequestMessages(from: chatMessages) + [ + ChatRequestMessage(role: "user", content: prompt) + ] + let responseID = UUID() + chatDraft = "" + chatMessageDestinationOverride = nil + isSendingMessage = true + chatMessages.append(ChatMessage( + role: .user, + text: prompt, + requestedGatewayRouteName: destination.routeName, + requestedDestinationTitle: destination.isAutomatic ? nil : destination.title + )) + chatMessages.append(ChatMessage( + id: responseID, + role: .assistant, + text: "", + state: .streaming + )) + saveActiveConversation() + + chatTask = Task { + do { + for try await event in client.streamChat( + messages: requestMessages, + destination: destination + ) { + guard let index = self.chatMessages.firstIndex(where: { $0.id == responseID }) else { + continue + } + switch event { + case let .decision(decision): + self.chatMessages[index].decision = decision + case let .text(fragment): + self.chatMessages[index].text += fragment + case .completed: + self.chatMessages[index].state = self.chatMessages[index].text.isEmpty + ? .failed + : .complete + if self.chatMessages[index].text.isEmpty { + self.chatMessages[index].text = "No model reply was delivered. Check the configured endpoint in Settings." + self.chatMessages[index].recoverySettingsSection = .gateway + } + } + } + if Task.isCancelled { + self.finishStoppedChatMessage(id: responseID) + return + } + if let index = self.chatMessages.firstIndex(where: { $0.id == responseID }), + self.chatMessages[index].state == .streaming { + self.finishFailedChatMessage( + id: responseID, + message: WayfinderClientError.invalidChatStream.localizedDescription + ) + return + } + self.isSendingMessage = false + self.chatTask = nil + self.saveActiveConversation() + self.refreshStats() + } catch { + if Task.isCancelled || error is CancellationError { + self.finishStoppedChatMessage(id: responseID) + } else { + self.finishFailedChatMessage( + id: responseID, + message: Self.chatErrorMessage(error, destination: destination), + recoverySettingsSection: Self.chatRecoverySettingsSection( + error, + destination: destination + ) + ) + } + } + } + } + private func finishStoppedChatMessage(id: UUID) { if let index = chatMessages.firstIndex(where: { $0.id == id }) { chatMessages[index].state = .stopped @@ -385,6 +464,11 @@ public final class AppState: ObservableObject { } } +private struct ChatSubmission { + let prompt: String + let destination: ChatDestination +} + public enum SettingsSection: String, CaseIterable, Codable, Identifiable, Sendable { case connections = "Connections" case gateway = "Gateway" diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatComposerView.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatComposerView.swift index b3e21742..0e2d9bf2 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatComposerView.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/ChatComposerView.swift @@ -4,33 +4,48 @@ public struct ChatComposerView: View { @Binding var draft: String @Binding var destination: ChatDestination let destinations: [ChatDestination] + let messageOverride: ChatDestination? let isSending: Bool let canSend: Bool + let onSelectMessageOverride: (ChatDestination) -> Void + let onClearMessageOverride: () -> Void let onSend: () -> Void let onStop: () -> Void @FocusState private var focused: Bool + @State private var selectedSuggestionID: String? + @State private var suggestionsDismissed = false public init( draft: Binding, destination: Binding, destinations: [ChatDestination], + messageOverride: ChatDestination?, isSending: Bool, canSend: Bool, + onSelectMessageOverride: @escaping (ChatDestination) -> Void, + onClearMessageOverride: @escaping () -> Void, onSend: @escaping () -> Void, onStop: @escaping () -> Void ) { self._draft = draft self._destination = destination self.destinations = destinations + self.messageOverride = messageOverride self.isSending = isSending self.canSend = canSend + self.onSelectMessageOverride = onSelectMessageOverride + self.onClearMessageOverride = onClearMessageOverride self.onSend = onSend self.onStop = onStop } public var body: some View { VStack(alignment: .leading, spacing: 4) { + if !visibleSuggestions.isEmpty { + suggestionMenu + } + TextField("Message Wayfinder…", text: $draft, axis: .vertical) .textFieldStyle(.plain) .font(.body) @@ -40,6 +55,25 @@ public struct ChatComposerView: View { .focused($focused) .accessibilityLabel("Message Wayfinder") .accessibilityHint("Press Return to send, or Shift-Return for a new line.") + .onKeyPress(.downArrow) { + guard !visibleSuggestions.isEmpty else { return .ignored } + moveSuggestionSelection(by: 1) + return .handled + } + .onKeyPress(.upArrow) { + guard !visibleSuggestions.isEmpty else { return .ignored } + moveSuggestionSelection(by: -1) + return .handled + } + .onKeyPress(.tab) { + guard selectCurrentSuggestion() else { return .ignored } + return .handled + } + .onKeyPress(.escape) { + guard !visibleSuggestions.isEmpty else { return .ignored } + suggestionsDismissed = true + return .handled + } .onKeyPress(.return, phases: .down) { keyPress in if keyPress.modifiers == [.shift] { return .ignored @@ -48,6 +82,9 @@ public struct ChatComposerView: View { guard keyPress.modifiers.isEmpty || keyPress.modifiers == [.command] else { return .handled } + if selectCurrentSuggestion() { + return .handled + } guard canSend, !isSending else { return .handled } @@ -56,7 +93,12 @@ public struct ChatComposerView: View { } HStack(alignment: .center, spacing: 10) { - destinationMenu + VStack(alignment: .leading, spacing: 5) { + if let messageOverride { + messageOverrideChip(messageOverride) + } + destinationMenu + } Spacer(minLength: 8) Button(action: isSending ? onStop : onSend) { Image(systemName: isSending ? "stop.fill" : "arrow.up") @@ -90,6 +132,137 @@ public struct ChatComposerView: View { .onAppear { focused = true } + .onChange(of: draft) { + suggestionsDismissed = false + if !enabledSuggestions.contains(where: { $0.id == selectedSuggestionID }) { + selectedSuggestionID = enabledSuggestions.first?.id + } + } + } + + private var suggestions: [ChatDestination] { + ChatDestinationMentionResolver.suggestions( + for: draft, + destinations: destinations + ) + } + + private var visibleSuggestions: [ChatDestination] { + guard messageOverride == nil, !suggestionsDismissed else { return [] } + return suggestions + } + + private var enabledSuggestions: [ChatDestination] { + visibleSuggestions.filter(\.isAvailable) + } + + private var suggestionMenu: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(visibleSuggestions) { option in + Button { + selectSuggestion(option) + } label: { + HStack(spacing: 10) { + Image(systemName: option.isChatGPTAccount ? "person.crop.circle" : "point.3.connected.trianglepath.dotted") + .frame(width: 16) + .foregroundStyle(option.isAvailable ? WayfinderTheme.local : .secondary) + VStack(alignment: .leading, spacing: 1) { + Text(option.title) + .font(.callout.weight(.medium)) + .foregroundStyle(.primary) + Text(option.detail) + .font(.caption) + .foregroundStyle(ChatWorkspaceChrome.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 10) + if !option.isAvailable { + Text("Unavailable") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + .background( + option.id == currentSuggestion?.id + ? WayfinderTheme.local.opacity(0.12) + : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + .buttonStyle(.plain) + .disabled(!option.isAvailable) + .accessibilityLabel(option.title) + .accessibilityValue( + "\(option.detail)\(option.isAvailable ? "" : ", unavailable")" + ) + .accessibilityHint("Routes this message only.") + } + } + } + .frame(maxHeight: 240) + .padding(4) + .background(ChatWorkspaceChrome.mutedFill, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ChatWorkspaceChrome.border) + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("Message destination suggestions") + } + + private var currentSuggestion: ChatDestination? { + enabledSuggestions.first(where: { $0.id == selectedSuggestionID }) + ?? enabledSuggestions.first + } + + private func moveSuggestionSelection(by offset: Int) { + guard !enabledSuggestions.isEmpty else { return } + let currentIndex = currentSuggestion.flatMap { current in + enabledSuggestions.firstIndex(where: { $0.id == current.id }) + } ?? 0 + let nextIndex = (currentIndex + offset + enabledSuggestions.count) + % enabledSuggestions.count + selectedSuggestionID = enabledSuggestions[nextIndex].id + } + + @discardableResult + private func selectCurrentSuggestion() -> Bool { + guard let currentSuggestion else { return false } + selectSuggestion(currentSuggestion) + return true + } + + private func selectSuggestion(_ option: ChatDestination) { + guard option.isAvailable else { return } + onSelectMessageOverride(option) + selectedSuggestionID = nil + suggestionsDismissed = true + focused = true + } + + private func messageOverrideChip(_ option: ChatDestination) -> some View { + Button(action: onClearMessageOverride) { + HStack(spacing: 5) { + Text("\(option.title) · this message") + Image(systemName: "xmark") + .font(.caption2.weight(.bold)) + } + .font(.caption.weight(.medium)) + .foregroundStyle(WayfinderTheme.local) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(WayfinderTheme.local.opacity(0.11), in: Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(isSending) + .accessibilityLabel("Remove \(option.title) override") + .accessibilityValue("Routes this message only") + .help("Remove this-message destination") } private var destinationMenu: some View { diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/RoutingOutputsPanel.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/RoutingOutputsPanel.swift index 65c4dcda..c96245aa 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/RoutingOutputsPanel.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/RoutingOutputsPanel.swift @@ -153,6 +153,13 @@ private struct PromptInspectorPreview: View { .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } + if let requestedDestination = turn.prompt.requestedDestinationTitle + ?? turn.prompt.requestedGatewayRouteName { + Label("Requested \(requestedDestination)", systemImage: "arrow.turn.down.right") + .font(.caption) + .foregroundStyle(ChatWorkspaceChrome.secondaryText) + .accessibilityLabel("Requested destination: \(requestedDestination)") + } } } } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift index a3ff6101..0d9afe8f 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift @@ -54,8 +54,11 @@ public struct WayfinderChatWindow: View { draft: $appState.chatDraft, destination: $appState.chatDestination, destinations: appState.chatDestinations, + messageOverride: appState.chatMessageDestinationOverride, isSending: appState.isSendingMessage, canSend: appState.canSendMessage, + onSelectMessageOverride: appState.selectChatMessageDestinationOverride, + onClearMessageOverride: appState.clearChatMessageDestinationOverride, onSend: { followsLatestTurn = true appState.sendChatDraft() diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatDestinationMentionTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatDestinationMentionTests.swift new file mode 100644 index 00000000..759fd944 --- /dev/null +++ b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatDestinationMentionTests.swift @@ -0,0 +1,389 @@ +import Foundation +import XCTest +@testable import WayfinderMacCore + +final class ChatDestinationMentionTests: XCTestCase { + func testLocalMentionResolvesToPreferLocalAndStripsToken() { + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@local Summarise this", + destinations: [] + ), + .resolved(destination: .preferLocal, prompt: "Summarise this") + ) + } + + func testHostedMentionResolvesToPreferHosted() { + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@HOSTED Use the strongest model", + destinations: [] + ), + .resolved(destination: .preferHosted, prompt: "Use the strongest model") + ) + } + + func testCanonicalRouteMentionRetainsCanonicalDestination() { + let destination = Self.appleDestination + + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@APPLE-LOCAL Explain this", + destinations: [destination] + ), + .resolved(destination: destination, prompt: "Explain this") + ) + } + + func testUniqueFriendlyAliasResolvesToCanonicalRoute() { + let destination = Self.appleDestination.withTitle("Mac Local") + + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@mac-local Explain this", + destinations: [destination] + ), + .resolved(destination: destination, prompt: "Explain this") + ) + } + + func testUniqueCodexMentionResolvesToAvailableChatGPTDestination() { + let destination = Self.codexDestination(routeName: "chatgpt-sol") + + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@codex Review this", + destinations: [destination] + ), + .resolved(destination: destination, prompt: "Review this") + ) + } + + func testMultipleCodexDestinationsRemainAmbiguous() { + let destinations = [ + Self.codexDestination(routeName: "chatgpt-sol"), + Self.codexDestination(routeName: "chatgpt-fast"), + ] + + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@codex Review this", + destinations: destinations + ), + .ambiguous(token: "codex", candidates: destinations) + ) + } + + func testUnknownMentionRemainsLiteralPromptText() { + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@someone Please review this", + destinations: [Self.appleDestination] + ), + .unknown(token: "someone") + ) + } + + func testAmbiguousFriendlyAliasDoesNotChooseArbitrarily() { + let destinations = [ + Self.appleDestination.withTitle("My Model"), + Self.codexDestination(routeName: "chatgpt-sol").withTitle("My Model"), + ] + + XCTAssertEqual( + ChatDestinationMentionResolver.resolve( + draft: "@my-model Review this", + destinations: destinations + ), + .ambiguous(token: "my-model", candidates: destinations) + ) + } + + func testSuggestionsRetainCanonicalDestinationsAndIncludeUnavailableRows() { + let available = Self.codexDestination(routeName: "chatgpt-sol") + let unavailable = Self.appleDestination.withAvailability(false) + + XCTAssertEqual( + ChatDestinationMentionResolver.suggestions( + for: "@", + destinations: [available, unavailable] + ), + [.preferLocal, .preferHosted, available, unavailable] + ) + } + + @MainActor + func testTypedOverrideDeliversCleanPromptWithoutMutatingSessionThenReturnsToAutomatic() async throws { + let recorder = MentionDestinationRecorder() + let state = AppState(client: MentionRecordingClient(recorder: recorder)) + state.chatDraft = "@local Summarise this" + + state.sendChatDraft() + try await waitUntil { !state.isSendingMessage } + + XCTAssertEqual(state.chatMessages.first?.text, "Summarise this") + XCTAssertEqual( + state.chatMessages.first?.requestedGatewayRouteName, + "prefer-local" + ) + XCTAssertEqual(state.chatDestination, .automatic) + XCTAssertNil(state.chatMessageDestinationOverride) + + state.chatDraft = "And now continue" + state.sendChatDraft() + try await waitUntil { !state.isSendingMessage } + + let deliveries = await recorder.deliveries() + XCTAssertEqual(deliveries.map(\.destination.gatewayModelValue), [ + "prefer-local", + "auto", + ]) + XCTAssertEqual(deliveries.map { $0.messages.last?.content }, [ + "Summarise this", + "And now continue", + ]) + } + + @MainActor + func testUnknownMentionIsDeliveredLiterallyThroughTheSessionDestination() async throws { + let recorder = MentionDestinationRecorder() + let state = AppState(client: MentionRecordingClient(recorder: recorder)) + state.chatDraft = "@someone Please review this" + + state.sendChatDraft() + try await waitUntil { !state.isSendingMessage } + + let recordedDeliveries = await recorder.deliveries() + let delivery = try XCTUnwrap(recordedDeliveries.first) + XCTAssertEqual(delivery.destination, .automatic) + XCTAssertEqual(delivery.messages.last?.content, "@someone Please review this") + XCTAssertEqual(state.chatMessages.first?.text, "@someone Please review this") + } + + @MainActor + func testUnavailableExplicitOverrideFailsClosed() async { + let recorder = MentionDestinationRecorder() + let state = AppState(client: MentionRecordingClient(recorder: recorder)) + state.selectChatMessageDestinationOverride( + Self.appleDestination.withAvailability(false) + ) + state.chatDraft = "Do not fall back" + + XCTAssertFalse(state.canSendMessage) + state.sendChatDraft() + + XCTAssertTrue(state.chatMessages.isEmpty) + let recordedDeliveries = await recorder.deliveries() + XCTAssertTrue(recordedDeliveries.isEmpty) + XCTAssertEqual( + state.chatMessageDestinationOverride?.gatewayModelValue, + "apple-local" + ) + } + + @MainActor + func testSelectingSuggestionStripsTokenAndShowsCanonicalPendingOverride() { + let state = AppState( + client: MentionRecordingClient(recorder: MentionDestinationRecorder()) + ) + state.chatDraft = "@mac-local" + let destination = Self.appleDestination.withTitle("Mac Local") + + state.selectChatMessageDestinationOverride(destination) + + XCTAssertTrue(state.chatDraft.isEmpty) + XCTAssertEqual(state.chatMessageDestinationOverride, destination) + XCTAssertEqual( + state.chatMessageDestinationOverride?.gatewayModelValue, + "apple-local" + ) + } + + @MainActor + func testRetryReusesOriginalRequestedDestinationInsteadOfCurrentSessionDestination() async throws { + let recorder = MentionDestinationRecorder() + let client = MentionRecordingClient( + recorder: recorder, + overview: Self.overview(with: [Self.appleEndpoint]) + ) + let state = AppState(client: client) + state.refreshStats() + try await waitUntil { !state.isRefreshingStats } + let destination = try XCTUnwrap( + state.chatDestinations.first { $0.gatewayModelValue == "apple-local" } + ) + state.selectChatMessageDestinationOverride(destination) + state.chatDraft = "Keep the original route" + + state.sendChatDraft() + try await waitUntil { !state.isSendingMessage } + XCTAssertEqual(state.chatMessages.last?.state, .failed) + + state.chatDestination = .preferHosted + state.retryLastChatTurn() + try await waitUntil { !state.isSendingMessage } + + let deliveries = await recorder.deliveries() + XCTAssertEqual(deliveries.map(\.destination.gatewayModelValue), [ + "apple-local", + "apple-local", + ]) + XCTAssertEqual(state.chatDestination.gatewayModelValue, "prefer-hosted") + } + + func testConversationWithoutRequestedDestinationFieldsStillDecodes() throws { + let message = ChatMessage( + role: .user, + text: "Old saved prompt", + requestedGatewayRouteName: "apple-local", + requestedDestinationTitle: "Apple Local" + ) + let conversation = ChatConversation(messages: [message]) + let encoded = try JSONEncoder().encode([conversation]) + var root = try XCTUnwrap( + JSONSerialization.jsonObject(with: encoded) as? [[String: Any]] + ) + var messages = try XCTUnwrap(root[0]["messages"] as? [[String: Any]]) + messages[0].removeValue(forKey: "requestedGatewayRouteName") + messages[0].removeValue(forKey: "requestedDestinationTitle") + root[0]["messages"] = messages + let legacyData = try JSONSerialization.data(withJSONObject: root) + + let decoded = try JSONDecoder().decode([ChatConversation].self, from: legacyData) + + XCTAssertEqual(decoded.first?.messages.first?.text, "Old saved prompt") + XCTAssertNil(decoded.first?.messages.first?.requestedGatewayRouteName) + XCTAssertNil(decoded.first?.messages.first?.requestedDestinationTitle) + } + + @MainActor + func testExistingPinnedComposerDestinationRemainsTheDeliveryDestination() async throws { + let recorder = MentionDestinationRecorder() + let state = AppState(client: MentionRecordingClient(recorder: recorder)) + state.chatDestination = .preferHosted + state.chatDraft = "Use the pinned session destination" + + state.sendChatDraft() + try await waitUntil { !state.isSendingMessage } + + let recordedDeliveries = await recorder.deliveries() + let delivery = try XCTUnwrap(recordedDeliveries.first) + XCTAssertEqual(delivery.destination, .preferHosted) + XCTAssertEqual(state.chatDestination.gatewayModelValue, "prefer-hosted") + XCTAssertEqual( + state.chatMessages.first?.requestedGatewayRouteName, + "prefer-hosted" + ) + } + + @MainActor + private func waitUntil( + timeoutNanoseconds: UInt64 = 1_000_000_000, + condition: @escaping @MainActor () -> Bool + ) async throws { + let started = ContinuousClock.now + while !condition() { + if ContinuousClock.now - started > .nanoseconds(Int64(timeoutNanoseconds)) { + XCTFail("Timed out waiting for Chat state") + return + } + try await Task.sleep(nanoseconds: 10_000_000) + } + } + + private static let appleEndpoint = EndpointDisplayStatus( + name: "apple-local", + providerName: "Apple Foundation Models", + modelName: "system-default", + state: .ready + ) + + private static let appleDestination = ChatDestination(endpoint: appleEndpoint) + + private static func codexDestination(routeName: String) -> ChatDestination { + ChatDestination( + routeName: routeName, + title: routeName, + detail: "ChatGPT · GPT-5.6 Sol", + providerName: "ChatGPT" + ) + } + + fileprivate static func overview( + with endpoints: [EndpointDisplayStatus] + ) -> GatewayOverview { + GatewayOverview( + gateway: .running(detail: "ready"), + hosted: .ready(detail: "ready"), + endpoints: endpoints, + routingStats: RoutingStats( + localPercent: 0, + cloudPercent: 0, + totalTurns: 0, + savedToday: 0, + savedLast30Days: 0, + cloudSpendToday: 0, + percentVsAlwaysCloud: 0, + averageRoutingTimeMilliseconds: 0, + updatedAt: Date(), + isRunning: true + ), + updatedAt: Date() + ) + } +} + +private actor MentionDestinationRecorder { + struct Delivery: Sendable { + let destination: ChatDestination + let messages: [ChatRequestMessage] + } + + private var recordedDeliveries: [Delivery] = [] + + func record(destination: ChatDestination, messages: [ChatRequestMessage]) { + recordedDeliveries.append(Delivery( + destination: destination, + messages: messages + )) + } + + func deliveries() -> [Delivery] { + recordedDeliveries + } +} + +private struct MentionRecordingClient: WayfinderClient { + let recorder: MentionDestinationRecorder + var overview: GatewayOverview? + + func route(prompt: String) async throws -> RoutingDecision { + throw WayfinderClientError.invalidChatStream + } + + func streamChat( + messages: [ChatRequestMessage] + ) -> AsyncThrowingStream { + streamChat(messages: messages, destination: .automatic) + } + + func streamChat( + messages: [ChatRequestMessage], + destination: ChatDestination + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + await recorder.record(destination: destination, messages: messages) + continuation.finish() + } + } + } + + func loadStats(range: StatsRange) async throws -> RoutingStats { + overview?.routingStats ?? ChatDestinationMentionTests.overview(with: []).routingStats + } + + func loadOverview() async throws -> GatewayOverview { + overview ?? ChatDestinationMentionTests.overview(with: []) + } +}