diff --git a/designs/WF-DESIGN-0020-mobile-chat-shell.md b/designs/WF-DESIGN-0020-mobile-chat-shell.md index bd96e91..65539f1 100644 --- a/designs/WF-DESIGN-0020-mobile-chat-shell.md +++ b/designs/WF-DESIGN-0020-mobile-chat-shell.md @@ -72,9 +72,11 @@ The detail presentation owns destination, execution boundary, routing tier, score, reason codes, fallback truth, and bounded error recovery. The transcript never reserves a permanent dashboard for those fields. -The current shell slice uses deterministic route previews only. It must say -that no provider was contacted and must not render a fabricated assistant -answer. +The initial shell slice used deterministic route previews only. The final +Phase 2 slice replaces the fabricated-response prohibition with a visibly +bounded deterministic provider: it may exercise ordered streaming, +cancellation, failure, interruption, and retry, but its copy and receipt must +state that no network request or live provider was used. ## Composer @@ -85,9 +87,9 @@ The composer contains: 3. an on-demand privacy-posture menu; 4. an enabled send control only when the trimmed draft is non-empty. -The send action is currently labelled `Preview route` for accessibility because -provider execution is outside this slice. That label changes to `Send message` -when real execution lands. +The send action is labelled `Send message`. While a deterministic or live +provider is active, that control becomes `Stop response`, and the composer +cannot submit a second concurrent request. The composer uses system materials and semantic colours. A strong permanent green outline is prohibited; the accent belongs on active actions and routing @@ -105,9 +107,10 @@ identity. - Starting a new chat clears only transient conversation state and returns to Chat. -## Out of scope +## Out of scope for the shell design -- provider execution or streaming; +- live provider execution; +- credentials and provider authentication; - durable thread storage; - account or credential setup; - Apple Foundation Models execution; @@ -123,7 +126,8 @@ This shell is accepted when: 2. the drawer reaches every existing section and dismisses by selection or scrim; 3. iPad exposes the same hierarchy through a native split view; -4. composer, suggestions, new chat, privacy selection, route preview, and +4. composer, suggestions, new chat, privacy selection, deterministic + execution, and receipt detail are functional; -5. preview copy never implies a provider responded; +5. deterministic-provider copy never implies a live provider responded; 6. source compiles for iOS and remote simulator checks pass. diff --git a/ios/WayfinderIOS/README.md b/ios/WayfinderIOS/README.md index 3c8d0f7..3f45bad 100644 --- a/ios/WayfinderIOS/README.md +++ b/ios/WayfinderIOS/README.md @@ -4,11 +4,13 @@ This target is the standalone native mobile shell governed by `WF-ROADMAP-0016`. It embeds the authoritative Rust routing core through `WayfinderRoutingBridge`; it does not require a Mac or localhost gateway. -The current shell previews deterministic route decisions without executing a -provider. Threads, drafts, terminal message states, and compact route receipts -persist locally through the `ConversationStore` boundary and a versioned -SwiftData implementation. Credentials, live providers, Apple Foundation -Models, and optional Mac pairing land in later review boundaries. +The current shell routes through the embedded core, then exercises the full +Chat lifecycle through a deterministic network-free provider. Ordered deltas, +stop, interruption recovery, failure, retry, threads, drafts, terminal message +states, and compact route receipts persist locally through the +`ConversationStore` boundary and a versioned SwiftData implementation. +Credentials, live providers, Apple Foundation Models, and optional Mac pairing +land in later review boundaries. ## Build diff --git a/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj b/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj index 86aecfb..6656888 100644 --- a/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj +++ b/ios/WayfinderIOS/WayfinderIOS.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 165C905840F4DCF56C27BF8F /* ConversationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89C6EA7388FA2CCF9F5865E /* ConversationStore.swift */; }; 17923336D4226B2F812CF7CA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0BB131143C117BDF129E06 /* SettingsView.swift */; }; 2663961FFDA2EEAEF58529A8 /* ConversationStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A79F801F4849CBD50C8A61 /* ConversationStoreTests.swift */; }; + 2FDE760EEAA633A74BF8B379 /* ChatExecution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E92E195F328A16C8A4C9159 /* ChatExecution.swift */; }; 383CBBDE76DCB1296EE2B2FF /* ChatTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */; }; 5289F042BE903630416D2DC7 /* ThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA6AA772008E2B7C904EA80 /* ThreadsView.swift */; }; A7AFF77BAFD88E83930B3DC6 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D3D621AD229ED0AF9703D04 /* AppModel.swift */; }; @@ -41,6 +42,7 @@ 5F6D147CFBC7918BCCD92B9E /* WayfinderIOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = WayfinderIOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 910171253EA965875F0C979A /* WayfinderIOSTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WayfinderIOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9B27149D56F517F17D1F971B /* WayfinderTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WayfinderTheme.swift; sourceTree = ""; }; + 9E92E195F328A16C8A4C9159 /* ChatExecution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatExecution.swift; sourceTree = ""; }; A28A0256D98D907F1BAF7A34 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; BE0BB131143C117BDF129E06 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; C17D8ECF221517590115EA9F /* AppModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModelTests.swift; sourceTree = ""; }; @@ -64,6 +66,7 @@ isa = PBXGroup; children = ( 0D3D621AD229ED0AF9703D04 /* AppModel.swift */, + 9E92E195F328A16C8A4C9159 /* ChatExecution.swift */, 25109E76A4BFCC3FD9297E97 /* ChatTabView.swift */, D89C6EA7388FA2CCF9F5865E /* ConversationStore.swift */, 375A787B3245E247D326C13E /* DestinationsView.swift */, @@ -204,6 +207,7 @@ buildActionMask = 2147483647; files = ( A7AFF77BAFD88E83930B3DC6 /* AppModel.swift in Sources */, + 2FDE760EEAA633A74BF8B379 /* ChatExecution.swift in Sources */, 383CBBDE76DCB1296EE2B2FF /* ChatTabView.swift in Sources */, 165C905840F4DCF56C27BF8F /* ConversationStore.swift in Sources */, C04837E936D39B59FC3DAEDE /* DestinationsView.swift in Sources */, diff --git a/ios/WayfinderIOS/WayfinderIOS/AppModel.swift b/ios/WayfinderIOS/WayfinderIOS/AppModel.swift index c9582b8..3d14517 100644 --- a/ios/WayfinderIOS/WayfinderIOS/AppModel.swift +++ b/ios/WayfinderIOS/WayfinderIOS/AppModel.swift @@ -75,6 +75,28 @@ enum RoutePreviewState: Equatable { case unavailable(String) } +enum ChatExecutionPhase: Equatable { + case idle + case routing(UUID) + case streaming(UUID) + case stopping(UUID) + + var requestID: UUID? { + switch self { + case .idle: + nil + case .routing(let requestID), + .streaming(let requestID), + .stopping(let requestID): + requestID + } + } + + var isActive: Bool { + requestID != nil + } +} + enum ConversationRetentionPolicy: String, CaseIterable, Identifiable { case thirtyDays case ninetyDays @@ -120,6 +142,7 @@ final class AppModel { var persistenceNotice: String? var isRestoringConversations = false var retentionPolicy: ConversationRetentionPolicy = .forever + var executionPhase: ChatExecutionPhase = .idle let destinations: [PreviewDestination] = [ PreviewDestination( @@ -142,16 +165,19 @@ final class AppModel { private let routingEngine: RoutingEngine private let conversationStore: any ConversationStore + private let providerExecutor: any ProviderExecutor private let now: () -> Date private var hasRestoredConversations = false private var draftSaveTask: Task? init( conversationStore: any ConversationStore = InMemoryConversationStore(), + providerExecutor: any ProviderExecutor = DeterministicMockProvider(), initialPersistenceNotice: String? = nil, now: @escaping () -> Date = Date.init ) { self.conversationStore = conversationStore + self.providerExecutor = providerExecutor self.persistenceNotice = initialPersistenceNotice self.now = now @@ -169,8 +195,9 @@ final class AppModel { } } - var canPreviewRoute: Bool { - !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + var canSendMessage: Bool { + !executionPhase.isActive + && !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var activeThread: ConversationThreadSnapshot? { @@ -206,29 +233,32 @@ final class AppModel { } await applyRetentionPolicy() + await interruptPendingMessages() } catch { persistenceNotice = "Wayfinder could not restore saved conversations. New chats remain available." } } - func previewRoute() async { + func sendMessage() async { let prompt = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !executionPhase.isActive else { + return + } guard !prompt.isEmpty else { - routePreviewState = .unavailable("Enter a message to preview its route.") + routePreviewState = .unavailable("Enter a message to send.") return } + let requestID = UUID() + executionPhase = .routing(requestID) submittedPrompt = prompt - var storedReceipt: StoredRouteReceipt? - var messageStatus = ConversationMessageStatus.completed - do { let plan = try routingEngine.plan( request: RoutingRequest( schemaVersion: 1, - requestId: UUID().uuidString, + requestId: requestID.uuidString, prompt: prompt, privacyPosture: privacyPosture.bridgeValue, requirements: RoutingRequirements( @@ -246,44 +276,131 @@ final class AppModel { let destination = destinations.first(where: { $0.id == selectedID }) else { routePreviewState = .unavailable( - "No preview destination is eligible under \(privacyPosture.title)." + "No destination is eligible under \(privacyPosture.title)." ) - await persistTurn( + await persistFailedTurn( prompt: prompt, - status: .failed, - receipt: nil + message: "No eligible destination is available under \(privacyPosture.title)." ) + executionPhase = .idle return } - routePreviewState = .routed( - RoutePreview( - destinationID: destination.id, - destinationName: destination.displayName, - score: plan.score, - recommendation: plan.recommendation, - executionSummary: destination.boundaryLabel - ) + let preview = RoutePreview( + destinationID: destination.id, + destinationName: destination.displayName, + score: plan.score, + recommendation: plan.recommendation, + executionSummary: destination.boundaryLabel ) - storedReceipt = StoredRouteReceipt( + routePreviewState = .routed(preview) + let receipt = StoredRouteReceipt( destinationID: destination.id, destinationName: destination.displayName, score: plan.score, recommendation: plan.recommendation, executionSummary: destination.boundaryLabel ) + let assistantMessageID = await beginTurn( + prompt: prompt, + receipt: receipt + ) + + if executionPhase == .stopping(requestID) { + await finishMessage(id: assistantMessageID, status: .stopped) + executionPhase = .idle + return + } + + executionPhase = .streaming(requestID) + let stream = await providerExecutor.stream( + ProviderExecutionRequest( + id: requestID, + prompt: prompt, + destinationID: destination.id + ) + ) + var reachedTerminalEvent = false + + do { + for try await event in stream { + guard executionPhase == .streaming(requestID) else { + break + } + + switch event { + case .delta(let delta): + await appendDelta(delta, to: assistantMessageID) + case .completed: + reachedTerminalEvent = true + await finishMessage( + id: assistantMessageID, + status: .completed + ) + } + } + + if !reachedTerminalEvent { + let status: ConversationMessageStatus = + executionPhase == .stopping(requestID) ? .stopped : .interrupted + await finishMessage(id: assistantMessageID, status: status) + } + } catch is CancellationError { + let status: ConversationMessageStatus = + executionPhase == .stopping(requestID) ? .stopped : .interrupted + await finishMessage(id: assistantMessageID, status: status) + } catch { + await failMessage( + id: assistantMessageID, + message: userFacingExecutionError(error) + ) + } } catch { routePreviewState = .unavailable( "Wayfinder could not calculate this route. Try a shorter message." ) - messageStatus = .failed + await persistFailedTurn( + prompt: prompt, + message: "Wayfinder could not calculate a route for this message." + ) } - await persistTurn( - prompt: prompt, - status: messageStatus, - receipt: storedReceipt - ) + if executionPhase.requestID == requestID { + executionPhase = .idle + } + } + + func previewRoute() async { + await sendMessage() + } + + func stopGenerating() async { + guard let requestID = executionPhase.requestID else { + return + } + + executionPhase = .stopping(requestID) + await providerExecutor.cancel(requestID: requestID) + } + + func retry(messageID: UUID) async { + guard + !executionPhase.isActive, + let thread = activeThread, + let failedIndex = thread.messages.firstIndex(where: { + $0.id == messageID + && $0.role == .assistant + && [.failed, .interrupted, .stopped].contains($0.status) + }), + let prompt = thread.messages[.. UUID { let timestamp = now() - let message = ConversationMessageSnapshot( + let userMessage = ConversationMessageSnapshot( id: UUID(), role: .user, content: prompt, createdAt: timestamp, - status: status, + status: .completed, + routeReceipt: nil + ) + let assistantMessage = ConversationMessageSnapshot( + id: UUID(), + role: .assistant, + content: "", + createdAt: timestamp, + status: .pending, routeReceipt: receipt ) @@ -409,7 +541,7 @@ final class AppModel { if let activeThread { thread = activeThread thread.updatedAt = timestamp - thread.messages.append(message) + thread.messages.append(contentsOf: [userMessage, assistantMessage]) thread.draft = "" } else { thread = ConversationThreadSnapshot( @@ -417,7 +549,7 @@ final class AppModel { title: ConversationThreadSnapshot.title(for: prompt), createdAt: timestamp, updatedAt: timestamp, - messages: [message], + messages: [userMessage, assistantMessage], draft: "" ) activeThreadID = thread.id @@ -434,6 +566,173 @@ final class AppModel { "This turn is visible now, but Wayfinder could not save it." upsertInMemory(thread) } + + return assistantMessage.id + } + + private func persistFailedTurn( + prompt: String, + message: String + ) async { + let timestamp = now() + let userMessage = ConversationMessageSnapshot( + id: UUID(), + role: .user, + content: prompt, + createdAt: timestamp, + status: .completed, + routeReceipt: nil + ) + let assistantMessage = ConversationMessageSnapshot( + id: UUID(), + role: .assistant, + content: message, + createdAt: timestamp, + status: .failed, + routeReceipt: nil + ) + + var thread: ConversationThreadSnapshot + if let activeThread { + thread = activeThread + thread.updatedAt = timestamp + thread.messages.append(contentsOf: [userMessage, assistantMessage]) + thread.draft = "" + } else { + thread = ConversationThreadSnapshot( + id: UUID(), + title: ConversationThreadSnapshot.title(for: prompt), + createdAt: timestamp, + updatedAt: timestamp, + messages: [userMessage, assistantMessage], + draft: "" + ) + activeThreadID = thread.id + } + + draft = "" + await saveUpdatedThread(thread) + } + + private func appendDelta( + _ delta: String, + to messageID: UUID + ) async { + guard !delta.isEmpty, var thread = activeThread, + let index = thread.messages.firstIndex(where: { $0.id == messageID }) + else { + return + } + + let message = thread.messages[index] + thread.messages[index] = ConversationMessageSnapshot( + id: message.id, + role: message.role, + content: message.content + delta, + createdAt: message.createdAt, + status: .streaming, + routeReceipt: message.routeReceipt + ) + thread.updatedAt = now() + await saveUpdatedThread(thread) + } + + private func finishMessage( + id messageID: UUID, + status: ConversationMessageStatus + ) async { + guard var thread = activeThread, + let index = thread.messages.firstIndex(where: { $0.id == messageID }) + else { + return + } + + let message = thread.messages[index] + guard message.status == .pending || message.status == .streaming else { + return + } + + thread.messages[index] = ConversationMessageSnapshot( + id: message.id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + status: status, + routeReceipt: message.routeReceipt + ) + thread.updatedAt = now() + await saveUpdatedThread(thread) + } + + private func failMessage( + id messageID: UUID, + message failureMessage: String + ) async { + guard var thread = activeThread, + let index = thread.messages.firstIndex(where: { $0.id == messageID }) + else { + return + } + + let message = thread.messages[index] + thread.messages[index] = ConversationMessageSnapshot( + id: message.id, + role: message.role, + content: message.content.isEmpty ? failureMessage : message.content, + createdAt: message.createdAt, + status: .failed, + routeReceipt: message.routeReceipt + ) + thread.updatedAt = now() + await saveUpdatedThread(thread) + } + + private func saveUpdatedThread( + _ thread: ConversationThreadSnapshot + ) async { + upsertInMemory(thread) + + do { + try await conversationStore.save(thread: thread) + await persistWorkspace() + } catch { + persistenceNotice = + "This turn is visible now, but Wayfinder could not save it." + } + } + + private func interruptPendingMessages() async { + for var thread in threads { + var changed = false + thread.messages = thread.messages.map { message in + guard message.status == .pending || message.status == .streaming else { + return message + } + changed = true + return ConversationMessageSnapshot( + id: message.id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + status: .interrupted, + routeReceipt: message.routeReceipt + ) + } + + if changed { + thread.updatedAt = now() + await saveUpdatedThread(thread) + } + } + } + + private func userFacingExecutionError(_ error: Error) -> String { + if let providerError = error as? ProviderExecutionError, + let description = providerError.errorDescription + { + return description + } + return "The preview provider could not finish this reply." } private func persistActiveDraft() async { @@ -514,15 +813,19 @@ final class AppModel { } private func restorePreview(from thread: ConversationThreadSnapshot) { - guard let message = thread.messages.last else { + guard let userMessage = thread.messages.last(where: { $0.role == .user }) + else { submittedPrompt = nil routePreviewState = .idle return } - submittedPrompt = message.content + submittedPrompt = userMessage.content + let assistantMessage = thread.messages.last(where: { + $0.role == .assistant + }) - if let receipt = message.routeReceipt { + if let receipt = assistantMessage?.routeReceipt { routePreviewState = .routed( RoutePreview( destinationID: receipt.destinationID, @@ -532,7 +835,7 @@ final class AppModel { executionSummary: receipt.executionSummary ) ) - } else if message.status == .failed { + } else if assistantMessage?.status == .failed { routePreviewState = .unavailable( "Wayfinder could not calculate a route for this message." ) diff --git a/ios/WayfinderIOS/WayfinderIOS/ChatExecution.swift b/ios/WayfinderIOS/WayfinderIOS/ChatExecution.swift new file mode 100644 index 0000000..22833ed --- /dev/null +++ b/ios/WayfinderIOS/WayfinderIOS/ChatExecution.swift @@ -0,0 +1,116 @@ +import Foundation + +struct ProviderExecutionRequest: Equatable, Sendable { + let id: UUID + let prompt: String + let destinationID: String +} + +enum ProviderExecutionEvent: Equatable, Sendable { + case delta(String) + case completed +} + +enum ProviderExecutionError: LocalizedError, Equatable, Sendable { + case rejected(String) + + var errorDescription: String? { + switch self { + case .rejected(let message): + message + } + } +} + +protocol ProviderExecutor: Sendable { + func stream( + _ request: ProviderExecutionRequest + ) async -> AsyncThrowingStream + + func cancel(requestID: UUID) async +} + +actor DeterministicMockProvider: ProviderExecutor { + enum Outcome: Equatable, Sendable { + case response(chunks: [String]) + case failure(afterChunks: [String], message: String) + } + + struct Configuration: Equatable, Sendable { + let outcome: Outcome + let delay: Duration + + static let conversational = Configuration( + outcome: .response( + chunks: [ + "This is a deterministic ", + "preview response from Wayfinder. ", + "No live provider was contacted.", + ] + ), + delay: .milliseconds(120) + ) + } + + private let configuration: Configuration + private var tasks: [UUID: Task] = [:] + + init(configuration: Configuration = .conversational) { + self.configuration = configuration + } + + func stream( + _ request: ProviderExecutionRequest + ) -> AsyncThrowingStream { + let (stream, continuation) = + AsyncThrowingStream.makeStream() + let configuration = configuration + + let task = Task { [weak self] in + do { + switch configuration.outcome { + case .response(let chunks): + for chunk in chunks { + try await Task.sleep(for: configuration.delay) + try Task.checkCancellation() + continuation.yield(.delta(chunk)) + } + continuation.yield(.completed) + continuation.finish() + + case .failure(let chunks, let message): + for chunk in chunks { + try await Task.sleep(for: configuration.delay) + try Task.checkCancellation() + continuation.yield(.delta(chunk)) + } + continuation.finish( + throwing: ProviderExecutionError.rejected(message) + ) + } + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + + await self?.removeTask(id: request.id) + } + + tasks[request.id] = task + continuation.onTermination = { @Sendable [weak self] _ in + Task { + await self?.cancel(requestID: request.id) + } + } + return stream + } + + func cancel(requestID: UUID) { + tasks.removeValue(forKey: requestID)?.cancel() + } + + private func removeTask(id: UUID) { + tasks[id] = nil + } +} diff --git a/ios/WayfinderIOS/WayfinderIOS/ChatTabView.swift b/ios/WayfinderIOS/WayfinderIOS/ChatTabView.swift index b5f4b64..8cd3805 100644 --- a/ios/WayfinderIOS/WayfinderIOS/ChatTabView.swift +++ b/ios/WayfinderIOS/WayfinderIOS/ChatTabView.swift @@ -20,47 +20,74 @@ struct ChatView: View { var body: some View { @Bindable var appModel = appModel - ScrollView { - LazyVStack(spacing: 26) { - if let thread = appModel.activeThread, - !thread.messages.isEmpty - { - ConversationTranscript( - thread: thread, - showReceipt: { presentedReceipt = $0 } - ) - } else { - ChatEmptyState(useSuggestion: useSuggestion) - .containerRelativeFrame(.vertical) { length, _ in - max(300, length * 0.62) - } + ScrollViewReader { scrollProxy in + ScrollView { + LazyVStack(spacing: 26) { + if let thread = appModel.activeThread, + !thread.messages.isEmpty + { + ConversationTranscript( + thread: thread, + showReceipt: { presentedReceipt = $0 }, + retry: { messageID in + Task { + await appModel.retry(messageID: messageID) + } + } + ) + } else { + ChatEmptyState(useSuggestion: useSuggestion) + .containerRelativeFrame(.vertical) { length, _ in + max(300, length * 0.62) + } + } } + .frame(maxWidth: 760) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.bottom, 24) } - .frame(maxWidth: 760) - .frame(maxWidth: .infinity) - .padding(.horizontal, 20) - .padding(.bottom, 24) - } - .scrollDismissesKeyboard(.interactively) - .background(Color(uiColor: .systemBackground)) - .safeAreaInset(edge: .bottom, spacing: 0) { - ComposerView( - draft: $appModel.draft, - privacyPosture: $appModel.privacyPosture, - canSubmit: appModel.canPreviewRoute, - submit: { - Task { - await appModel.previewRoute() - } + .onChange(of: appModel.activeThread?.messages.last) { + guard let message = appModel.activeThread?.messages.last else { + return + } + withAnimation(.easeOut(duration: 0.2)) { + scrollProxy.scrollTo(message.id, anchor: .bottom) + } + } + .onChange(of: appModel.activeThreadID) { + guard let message = appModel.activeThread?.messages.last else { + return } - ) - .focused($composerFocused) - .frame(maxWidth: 780) - .padding(.horizontal, 12) - .padding(.top, 8) - .padding(.bottom, 8) - .frame(maxWidth: .infinity) - .background(.ultraThinMaterial) + scrollProxy.scrollTo(message.id, anchor: .bottom) + } + .scrollDismissesKeyboard(.interactively) + .background(Color(uiColor: .systemBackground)) + .safeAreaInset(edge: .bottom, spacing: 0) { + ComposerView( + draft: $appModel.draft, + privacyPosture: $appModel.privacyPosture, + canSubmit: appModel.canSendMessage, + isGenerating: appModel.executionPhase.isActive, + submit: { + Task { + await appModel.sendMessage() + } + }, + stop: { + Task { + await appModel.stopGenerating() + } + } + ) + .focused($composerFocused) + .frame(maxWidth: 780) + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 8) + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + } } .navigationTitle("Wayfinder") .navigationBarTitleDisplayMode(.inline) @@ -97,6 +124,7 @@ struct ChatView: View { } label: { Image(systemName: "square.and.pencil") } + .disabled(appModel.executionPhase.isActive) .accessibilityLabel("New chat") } } @@ -160,60 +188,131 @@ private struct ChatEmptyState: View { private struct ConversationTranscript: View { let thread: ConversationThreadSnapshot let showReceipt: (RoutePreview) -> Void + let retry: (UUID) -> Void var body: some View { VStack(spacing: 30) { ForEach(thread.messages) { message in - VStack(spacing: 16) { - HStack { - Spacer(minLength: 48) - Text(message.content) - .padding(.horizontal, 16) - .padding(.vertical, 11) - .background( - Color(uiColor: .secondarySystemBackground), - in: RoundedRectangle(cornerRadius: 20) - ) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("You") - .accessibilityValue(message.content) - - Text( - "Wayfinder calculated where this message would run. This preview did not contact a model." + if message.role == .user { + UserMessage(message: message) + } else if message.role == .assistant { + AssistantMessage( + message: message, + showReceipt: showReceipt, + retry: retry ) - .frame(maxWidth: .infinity, alignment: .leading) - - if let receipt = message.routeReceipt { - Button { - showReceipt(receipt.routePreview) - } label: { - HStack(spacing: 7) { - Image(systemName: boundaryImage(for: receipt)) - .foregroundStyle(WayfinderTheme.accent) - Text("Would run \(receipt.executionSummary.lowercased())") - .fontWeight(.medium) - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - } - .font(.footnote) + } + } + } + .padding(.top, 24) + } +} + +private struct UserMessage: View { + let message: ConversationMessageSnapshot + + var body: some View { + HStack { + Spacer(minLength: 48) + Text(message.content) + .padding(.horizontal, 16) + .padding(.vertical, 11) + .background( + Color(uiColor: .secondarySystemBackground), + in: RoundedRectangle(cornerRadius: 20) + ) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("You") + .accessibilityValue(message.content) + } +} + +private struct AssistantMessage: View { + let message: ConversationMessageSnapshot + let showReceipt: (RoutePreview) -> Void + let retry: (UUID) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if message.content.isEmpty, + message.status == .pending || message.status == .streaming + { + HStack(spacing: 10) { + ProgressView() + Text("Wayfinder is responding…") + .foregroundStyle(.secondary) + } + } else if message.content.isEmpty { + Text(statusDescription) + .foregroundStyle(.secondary) + } else { + Text(message.content) + .textSelection(.enabled) + } + + HStack(spacing: 12) { + if let receipt = message.routeReceipt { + Button { + showReceipt(receipt.routePreview) + } label: { + HStack(spacing: 7) { + Image(systemName: boundaryImage(for: receipt)) + .foregroundStyle(WayfinderTheme.accent) + Text("Routed to \(receipt.executionSummary.lowercased())") + .fontWeight(.medium) + Image(systemName: "info.circle") + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - .accessibilityHint("Shows routing details") - .frame(maxWidth: .infinity, alignment: .leading) - } else if message.status == .failed { - Label( - "No eligible route", - systemImage: "exclamationmark.triangle.fill" - ) - .font(.footnote.weight(.medium)) - .foregroundStyle(.orange) - .frame(maxWidth: .infinity, alignment: .leading) + .font(.footnote) + } + .buttonStyle(.plain) + .accessibilityHint("Shows routing details") + } + + if canRetry { + Button("Retry") { + retry(message.id) } + .font(.footnote.weight(.semibold)) } } + + if message.status != .completed, + message.status != .pending, + message.status != .streaming + { + Label(statusDescription, systemImage: statusImage) + .font(.footnote.weight(.medium)) + .foregroundStyle(message.status == .failed ? .orange : .secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + } + + private var canRetry: Bool { + [.failed, .interrupted, .stopped].contains(message.status) + } + + private var statusDescription: String { + switch message.status { + case .pending: "Responding" + case .streaming: "Responding" + case .completed: "Completed" + case .stopped: "You stopped this response" + case .interrupted: "This response was interrupted" + case .failed: "Reply failed" + } + } + + private var statusImage: String { + switch message.status { + case .failed: "exclamationmark.triangle.fill" + case .stopped: "stop.circle" + case .interrupted: "arrow.clockwise.circle" + case .pending, .streaming, .completed: "checkmark.circle" } - .padding(.top, 24) } private func boundaryImage(for receipt: StoredRouteReceipt) -> String { @@ -252,7 +351,7 @@ private struct RouteReceiptSheet: View { Section { Label( - "No provider was contacted and no message left this device.", + "This response used the deterministic Phase 2 provider. No network request was made.", systemImage: "checkmark.shield" ) .foregroundStyle(.secondary) @@ -277,7 +376,9 @@ private struct ComposerView: View { @Binding var draft: String @Binding var privacyPosture: PrivacyPostureOption let canSubmit: Bool + let isGenerating: Bool let submit: () -> Void + let stop: () -> Void var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -292,6 +393,7 @@ private struct ComposerView: View { submit() } } + .disabled(isGenerating) HStack(spacing: 10) { Menu { @@ -327,19 +429,23 @@ private struct ComposerView: View { .accessibilityLabel("Privacy") .accessibilityValue(privacyPosture.title) - Button(action: submit) { - Image(systemName: "arrow.up") - .font(.headline) - .foregroundStyle(canSubmit ? Color.white : Color.secondary) + Button(action: isGenerating ? stop : submit) { + Image(systemName: isGenerating ? "stop.fill" : "arrow.up") + .font(isGenerating ? .caption.weight(.bold) : .headline) + .foregroundStyle( + canSubmit || isGenerating ? Color.white : Color.secondary + ) .frame(width: 34, height: 34) .background( - canSubmit ? WayfinderTheme.accent : Color(uiColor: .tertiarySystemFill), + canSubmit || isGenerating + ? WayfinderTheme.accent + : Color(uiColor: .tertiarySystemFill), in: Circle() ) } .buttonStyle(.plain) - .disabled(!canSubmit) - .accessibilityLabel("Preview route") + .disabled(!canSubmit && !isGenerating) + .accessibilityLabel(isGenerating ? "Stop response" : "Send message") } } .padding(.horizontal, 14) diff --git a/ios/WayfinderIOS/WayfinderIOS/ConversationStore.swift b/ios/WayfinderIOS/WayfinderIOS/ConversationStore.swift index bed8e7f..f18a14a 100644 --- a/ios/WayfinderIOS/WayfinderIOS/ConversationStore.swift +++ b/ios/WayfinderIOS/WayfinderIOS/ConversationStore.swift @@ -9,6 +9,7 @@ enum ConversationMessageRole: String, Codable, Sendable { enum ConversationMessageStatus: String, Codable, Sendable { case pending + case streaming case completed case stopped case interrupted diff --git a/ios/WayfinderIOS/WayfinderIOS/RootView.swift b/ios/WayfinderIOS/WayfinderIOS/RootView.swift index ff058ee..4be4b43 100644 --- a/ios/WayfinderIOS/WayfinderIOS/RootView.swift +++ b/ios/WayfinderIOS/WayfinderIOS/RootView.swift @@ -42,32 +42,11 @@ struct RootView: View { } private var compactWidthLayout: some View { - @Bindable var appModel = appModel - - return GeometryReader { proxy in + GeometryReader { proxy in ZStack(alignment: .leading) { - TabView(selection: $appModel.selectedTab) { - ChatTabView(openSidebar: openSidebar) - .tag(AppTab.chat) - - NavigationStack { - ThreadsView(openSidebar: openSidebar) - } - .tag(AppTab.threads) - - NavigationStack { - DestinationsView(openSidebar: openSidebar) - } - .tag(AppTab.destinations) - - NavigationStack { - SettingsView(openSidebar: openSidebar) - } - .tag(AppTab.settings) - } - .toolbar(.hidden, for: .tabBar) - .allowsHitTesting(!showsSidebar) - .accessibilityHidden(showsSidebar) + compactSelectedDetail + .allowsHitTesting(!showsSidebar) + .accessibilityHidden(showsSidebar) if showsSidebar { Color.black.opacity(0.28) @@ -86,6 +65,26 @@ struct RootView: View { .animation(.snappy(duration: 0.28), value: showsSidebar) } + @ViewBuilder + private var compactSelectedDetail: some View { + switch appModel.selectedTab { + case .chat: + ChatTabView(openSidebar: openSidebar) + case .threads: + NavigationStack { + ThreadsView(openSidebar: openSidebar) + } + case .destinations: + NavigationStack { + DestinationsView(openSidebar: openSidebar) + } + case .settings: + NavigationStack { + SettingsView(openSidebar: openSidebar) + } + } + } + private var regularWidthLayout: some View { NavigationSplitView { AppSidebarView(select: select) @@ -152,6 +151,7 @@ private struct AppSidebarView: View { } .buttonStyle(.plain) .font(.body.weight(.medium)) + .disabled(appModel.executionPhase.isActive) .accessibilityHint("Starts a new conversation") VStack(alignment: .leading, spacing: 8) { diff --git a/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift b/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift index 3539e57..290812d 100644 --- a/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift +++ b/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift @@ -54,7 +54,7 @@ final class AppModelTests: XCTestCase { XCTAssertEqual( model.routePreviewState, - .unavailable("Enter a message to preview its route.") + .unavailable("Enter a message to send.") ) } @@ -112,11 +112,160 @@ final class AppModelTests: XCTestCase { XCTAssertEqual(model.threads.count, 1) XCTAssertEqual( - model.activeThread?.messages.map(\.content), + model.activeThread?.messages + .filter { $0.role == .user } + .map(\.content), ["First turn", "Second turn"] ) } + func testDeterministicProviderStreamsOrderedAssistantReply() async { + let provider = DeterministicMockProvider( + configuration: .init( + outcome: .response(chunks: ["First ", "second ", "third."]), + delay: .zero + ) + ) + let model = AppModel(providerExecutor: provider) + model.draft = "Stream this" + + await model.sendMessage() + + let messages = model.activeThread?.messages ?? [] + XCTAssertEqual(messages.map(\.role), [.user, .assistant]) + XCTAssertEqual(messages[1].content, "First second third.") + XCTAssertEqual(messages[1].status, .completed) + XCTAssertNotNil(messages[1].routeReceipt) + XCTAssertEqual(model.executionPhase, .idle) + } + + func testProviderFailurePreservesPartialReplyAndOffersRetryState() async { + let provider = DeterministicMockProvider( + configuration: .init( + outcome: .failure( + afterChunks: ["Partial reply"], + message: "The deterministic provider rejected this request." + ), + delay: .zero + ) + ) + let model = AppModel(providerExecutor: provider) + model.draft = "Fail after output" + + await model.sendMessage() + + let assistant = model.activeThread?.messages.last + XCTAssertEqual(assistant?.role, .assistant) + XCTAssertEqual(assistant?.content, "Partial reply") + XCTAssertEqual(assistant?.status, .failed) + } + + func testStoppingGenerationProducesOneStoppedAssistantMessage() async { + let provider = DeterministicMockProvider( + configuration: .init( + outcome: .response(chunks: ["Too ", "slow"]), + delay: .seconds(5) + ) + ) + let model = AppModel(providerExecutor: provider) + model.draft = "Stop this" + let sendTask = Task { + await model.sendMessage() + } + + await waitUntil { + if case .streaming = model.executionPhase { + return true + } + return false + } + await model.stopGenerating() + await sendTask.value + + let assistantMessages = + model.activeThread?.messages.filter { $0.role == .assistant } ?? [] + XCTAssertEqual(assistantMessages.count, 1) + XCTAssertEqual(assistantMessages[0].status, .stopped) + XCTAssertEqual(model.executionPhase, .idle) + } + + func testRetryPreservesFailedAttemptAndCreatesNewAttempt() async { + let provider = DeterministicMockProvider( + configuration: .init( + outcome: .failure(afterChunks: [], message: "Preview failed."), + delay: .zero + ) + ) + let model = AppModel(providerExecutor: provider) + model.draft = "Try this" + await model.sendMessage() + let failedID = try! XCTUnwrap(model.activeThread?.messages.last?.id) + + await model.retry(messageID: failedID) + + let messages = model.activeThread?.messages ?? [] + XCTAssertEqual(messages.count, 4) + XCTAssertEqual( + messages.filter { $0.role == .assistant }.map(\.status), + [.failed, .failed] + ) + XCTAssertEqual( + messages.filter { $0.role == .user }.map(\.content), + [ + "Try this", "Try this", + ]) + XCTAssertEqual(messages.first { $0.id == failedID }?.status, .failed) + } + + func testRestoreMarksPendingAssistantMessageInterrupted() async { + let store = InMemoryConversationStore() + let threadID = UUID() + let pendingID = UUID() + await store.save( + thread: ConversationThreadSnapshot( + id: threadID, + title: "Interrupted", + createdAt: .distantPast, + updatedAt: .distantPast, + messages: [ + ConversationMessageSnapshot( + id: UUID(), + role: .user, + content: "Continue", + createdAt: .distantPast, + status: .completed, + routeReceipt: nil + ), + ConversationMessageSnapshot( + id: pendingID, + role: .assistant, + content: "Partial", + createdAt: .distantPast, + status: .pending, + routeReceipt: nil + ), + ], + draft: "" + ) + ) + await store.save( + workspace: ConversationWorkspaceSnapshot( + activeThreadID: threadID, + draft: "", + retentionDays: nil, + updatedAt: .distantPast + ) + ) + let model = AppModel(conversationStore: store) + + await model.restoreConversations() + + XCTAssertEqual( + model.activeThread?.messages.first { $0.id == pendingID }?.status, + .interrupted + ) + } + func testNewChatDraftRestoresWithoutCreatingThread() async { let store = InMemoryConversationStore() let firstModel = AppModel(conversationStore: store) @@ -161,4 +310,18 @@ final class AppModelTests: XCTestCase { XCTAssertEqual(model.retentionPolicy, .thirtyDays) XCTAssertTrue(model.threads.isEmpty) } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @MainActor () -> Bool + ) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while !condition(), clock.now < deadline { + await Task.yield() + } + + XCTAssertTrue(condition(), "Timed out waiting for state transition") + } } diff --git a/ios/WayfinderIOS/WayfinderIOSTests/ConversationStoreTests.swift b/ios/WayfinderIOS/WayfinderIOSTests/ConversationStoreTests.swift index aba4611..4e08264 100644 --- a/ios/WayfinderIOS/WayfinderIOSTests/ConversationStoreTests.swift +++ b/ios/WayfinderIOS/WayfinderIOSTests/ConversationStoreTests.swift @@ -48,6 +48,28 @@ final class ConversationStoreTests: XCTestCase { XCTAssertEqual(threads.map(\.id), [newer.id, older.id]) } + func testStreamingAssistantStateRoundTrips() async throws { + let store = try makeStore() + let timestamp = Date(timeIntervalSince1970: 100) + var thread = makeThread(prompt: "Stream this", timestamp: timestamp) + thread.messages.append( + ConversationMessageSnapshot( + id: UUID(), + role: .assistant, + content: "Partial", + createdAt: timestamp, + status: .streaming, + routeReceipt: nil + ) + ) + + try await store.save(thread: thread) + + let restored = try await store.thread(id: thread.id) + XCTAssertEqual(restored?.messages.last?.status, .streaming) + XCTAssertEqual(restored?.messages.last?.content, "Partial") + } + func testDeleteActiveThreadClearsWorkspace() async throws { let store = try makeStore() let thread = makeThread(prompt: "Delete me") diff --git a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md index fb57635..5a10ec7 100644 --- a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md +++ b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md @@ -189,10 +189,13 @@ build. Implementation note: the native shell has universal iPhone/iPad targets, root Observation state, adaptive drawer/split navigation, and an honest -routing-preview flow backed by the generated Rust bridge. The second Phase 2 -slice adds the `ConversationStore` boundary, versioned SwiftData model actor, -thread and draft restoration, retention, deterministic export, deletion, and -bounded storage failure recovery. It intentionally does not execute a +routing flow backed by the generated Rust bridge. The persistence slice adds +the `ConversationStore` boundary, versioned SwiftData model actor, thread and +draft restoration, retention, deterministic export, deletion, and bounded +storage failure recovery. The final Phase 2 slice adds a deterministic, +network-free provider contract and the complete Chat lifecycle: ordered +deltas, one terminal assistant message, cancellation, interruption recovery, +failure, and retry. It intentionally does not add credentials or a live provider. - create iPhone/iPad Xcode targets;