From 310b54a26bc7dc97f1ef8905b5c1c4e3ffac4a22 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Tue, 21 Jul 2026 07:09:52 +0100 Subject: [PATCH] feat(chat): preserve conversation history [roadmap:desktop-v0.1.0] --- .../Models/ChatConversation.swift | 34 +++ .../Services/ChatConversationStore.swift | 64 ++++++ .../Sources/WayfinderMac/State/AppState.swift | 57 ++++- .../UI/Chat/ChatSidebarView.swift | 210 ++++++------------ .../UI/Chat/WayfinderChatWindow.swift | 29 +-- .../WayfinderMac/WayfinderMacApp.swift | 5 +- .../WayfinderMacTests/ChatStateTests.swift | 50 +++++ 7 files changed, 286 insertions(+), 163 deletions(-) create mode 100644 macos/WayfinderMac/Sources/WayfinderMac/Models/ChatConversation.swift create mode 100644 macos/WayfinderMac/Sources/WayfinderMac/Services/ChatConversationStore.swift 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/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/State/AppState.swift b/macos/WayfinderMac/Sources/WayfinderMac/State/AppState.swift index 7b61634e..8caff4de 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() { @@ -318,6 +347,7 @@ public final class AppState: ObservableObject { } isSendingMessage = false chatTask = nil + saveActiveConversation() } private func finishFailedChatMessage( @@ -332,8 +362,27 @@ 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 { 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/WayfinderChatWindow.swift b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift index 47fbc540..a3ff6101 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/UI/Chat/WayfinderChatWindow.swift @@ -3,7 +3,6 @@ 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 followsLatestTurn = true @@ -13,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, @@ -45,11 +41,11 @@ public struct WayfinderChatWindow: View { 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 @@ -76,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 { @@ -117,7 +113,6 @@ public struct WayfinderChatWindow: View { private func resetWorkspace() { selectedTurnID = nil - routeFilter = .all searchText = "" followsLatestTurn = true } diff --git a/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift b/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift index 7922503f..c434626c 100644 --- a/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift +++ b/macos/WayfinderMac/Sources/WayfinderMac/WayfinderMacApp.swift @@ -20,7 +20,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { 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 diff --git a/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift b/macos/WayfinderMac/Tests/WayfinderMacTests/ChatStateTests.swift index 6d62f0fe..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)