diff --git a/clients/macos/vellum-assistant/App/AppDelegate+Conversations.swift b/clients/macos/vellum-assistant/App/AppDelegate+Conversations.swift index e6259587222..e2808237c0b 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate+Conversations.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate+Conversations.swift @@ -113,7 +113,7 @@ extension AppDelegate { let conversation = conversationManager.conversations.first(where: { $0.conversationId == conversationId }) else { return false } - conversationManager.activeConversationId = conversation.id + conversationManager.activateConversation(conversation.id) // Switch the main content area to the chat so the user sees it // even if they were last viewing a panel, app, or other non-chat view. mainWindow?.windowState.selection = nil diff --git a/clients/macos/vellum-assistant/App/AppDelegate+InputMonitors.swift b/clients/macos/vellum-assistant/App/AppDelegate+InputMonitors.swift index 3445250c615..cb45cd5d614 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate+InputMonitors.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate+InputMonitors.swift @@ -669,7 +669,7 @@ extension AppDelegate { func handleQuickInputSelectConversation(_ conversationId: UUID) { showMainWindow() guard let mainWindow else { return } - mainWindow.conversationManager.activeConversationId = conversationId + mainWindow.conversationManager.activateConversation(conversationId) } /// Tears down and re-registers the global "Open Vellum" hotkey based on diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift index b9b9bbc7bee..3e1a2821b2e 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift @@ -19,8 +19,12 @@ final class ConversationListStore { // MARK: - Stored Properties - var conversations: [ConversationModel] = [] - var groups: [ConversationGroup] = [] + var conversations: [ConversationModel] = [] { + didSet { recomputeDerivedProperties() } + } + var groups: [ConversationGroup] = [] { + didSet { recomputeDerivedProperties() } + } /// Whether the daemon returned a non-empty groups array, indicating it supports /// the group system. When true, `groupId: null` from the server means "explicitly @@ -115,71 +119,79 @@ final class ConversationListStore { } } - // MARK: - Computed Properties + // MARK: - Cached Derived Properties + // + // These are stored rather than computed so that multiple SwiftUI views reading + // the same property within a single layout pass share one computation instead + // of each re-running the O(N log N) sort + O(N) filter independently. + // Recomputed once per `conversations` or `groups` mutation via `didSet`. - var sortedGroups: [ConversationGroup] { - groups.sorted { $0.sortPosition < $1.sortPosition } - } + private(set) var sortedGroups: [ConversationGroup] = [] /// Conversations organized by group. Groups appear in sortPosition order; /// ungrouped conversations (including orphans with unknown groupId) appear last. - /// Buckets conversations in a single pass over `visibleConversations` (O(N)) - /// instead of filtering per group (O(N*G)). - var groupedConversations: [(group: ConversationGroup?, conversations: [ConversationModel])] { - let visible = visibleConversations - let knownGroupIds = Set(groups.map(\.id)) + private(set) var groupedConversations: [(group: ConversationGroup?, conversations: [ConversationModel])] = [] + + /// Non-archived, non-private conversations sorted for the sidebar. + private(set) var visibleConversations: [ConversationModel] = [] + + /// Count of visible conversations with unseen assistant messages (dock badge). + private(set) var unseenVisibleConversationCount: Int = 0 + + private(set) var archivedConversations: [ConversationModel] = [] + + /// Recompute all derived sidebar properties from `conversations` and `groups`. + /// Called from `conversations.didSet` and `groups.didSet`. Skips work when + /// `conversations` is empty to avoid wasted computation (e.g. when `groups` + /// is assigned before `conversations` during restoration). + private func recomputeDerivedProperties() { + guard !conversations.isEmpty else { + sortedGroups = groups.sorted { $0.sortPosition < $1.sortPosition } + groupedConversations = [] + visibleConversations = [] + unseenVisibleConversationCount = 0 + archivedConversations = [] + return + } + let currentSortedGroups = groups.sorted { $0.sortPosition < $1.sortPosition } + sortedGroups = currentSortedGroups + + let positionMap = Dictionary(uniqueKeysWithValues: groups.map { ($0.id, $0.sortPosition) }) + let currentVisible = conversations + .filter { !$0.isArchived && $0.kind != .private } + .sorted { visibleConversationSortOrder($0, $1, positionMap: positionMap) } + visibleConversations = currentVisible + + unseenVisibleConversationCount = conversations.count { + !$0.isArchived && $0.kind != .private && $0.hasUnseenLatestAssistantMessage + } - // Single-pass bucketing: group conversations by groupId + archivedConversations = conversations.filter { $0.isArchived } + + // Bucket visible conversations by group in a single pass (O(N)). + let knownGroupIds = Set(groups.map(\.id)) var buckets: [String: [ConversationModel]] = [:] var ungrouped: [ConversationModel] = [] var orphaned: [ConversationModel] = [] - for conversation in visible { + for conversation in currentVisible { if let gid = conversation.groupId { if knownGroupIds.contains(gid) { buckets[gid, default: []].append(conversation) } else { - orphaned.append(conversation) // unknown groupId + orphaned.append(conversation) } } else { ungrouped.append(conversation) } } - // Always include all groups so the view layer can decide visibility. - // Non-system groups always appear (so "New Group" is visible immediately). - var result: [(ConversationGroup?, [ConversationModel])] = [] - for group in sortedGroups { - result.append((group, buckets[group.id] ?? [])) + var grouped: [(ConversationGroup?, [ConversationModel])] = [] + for group in currentSortedGroups { + grouped.append((group, buckets[group.id] ?? [])) } - - // Ungrouped + orphaned (unknown groupId) -- always last - result.append((nil, ungrouped + orphaned)) - - return result - } - - /// Conversations that are not archived — used by the UI to populate the sidebar. - /// Sorted: grouped conversations first (by group sortPosition), then ungrouped. - /// Within each group, conversations sort by displayOrder ascending, then recency descending. - /// Conversations move to the top when messages are sent or received, but NOT when clicked/selected. - var visibleConversations: [ConversationModel] { - let positionMap = Dictionary(uniqueKeysWithValues: groups.map { ($0.id, $0.sortPosition) }) - return conversations - .filter { !$0.isArchived && $0.kind != .private } - .sorted { visibleConversationSortOrder($0, $1, positionMap: positionMap) } - } - - /// Count of visible (non-archived, non-private) conversations with unseen assistant messages. - /// Used by AppDelegate to drive the dock badge. - /// Filters `conversations` directly instead of calling `visibleConversations` to avoid - /// an unnecessary O(N log N) sort — only the count is needed. - var unseenVisibleConversationCount: Int { - conversations.count { !$0.isArchived && $0.kind != .private && $0.hasUnseenLatestAssistantMessage } - } - - var archivedConversations: [ConversationModel] { - conversations.filter { $0.isArchived } + grouped.append((nil, ungrouped + orphaned)) + groupedConversations = grouped } // MARK: - Sort Helpers @@ -301,16 +313,19 @@ final class ConversationListStore { guard let index = conversations.firstIndex(where: { $0.id == id }) else { return } // Remember the pre-pin groupId so unpin can restore the original group. prePinGroupIds[id] = conversations[index].groupId - conversations[index].groupId = ConversationGroup.pinned.id + var conversation = conversations[index] + conversation.groupId = ConversationGroup.pinned.id let maxOrder = conversations .filter { $0.groupId == ConversationGroup.pinned.id && $0.id != id } .compactMap(\.displayOrder).max() ?? -1 - conversations[index].displayOrder = maxOrder + 1 + conversation.displayOrder = maxOrder + 1 + conversations[index] = conversation sendReorderConversations() } func unpinConversation(id: UUID) { guard let index = conversations.firstIndex(where: { $0.id == id }) else { return } + var conversation = conversations[index] // Restore the group the conversation belonged to before pinning. // Falls back to heuristic routing when no pre-pin groupId was recorded // (e.g. conversations pinned before this feature was added). @@ -318,15 +333,16 @@ final class ConversationListStore { stored == nil || groups.contains(where: { $0.id == stored }) { // Restore the saved group only if it still exists (or was nil/ungrouped). // If the group was deleted while pinned, fall through to heuristics. - conversations[index].groupId = stored - } else if conversations[index].isScheduleConversation { - conversations[index].groupId = ConversationGroup.scheduled.id - } else if conversations[index].shouldReturnToBackgroundOnUnpin { - conversations[index].groupId = ConversationGroup.background.id + conversation.groupId = stored + } else if conversation.isScheduleConversation { + conversation.groupId = ConversationGroup.scheduled.id + } else if conversation.shouldReturnToBackgroundOnUnpin { + conversation.groupId = ConversationGroup.background.id } else { - conversations[index].groupId = nil + conversation.groupId = nil } - conversations[index].displayOrder = nil + conversation.displayOrder = nil + conversations[index] = conversation sendReorderConversations() } @@ -339,19 +355,21 @@ final class ConversationListStore { if groupId == ConversationGroup.pinned.id { prePinGroupIds[conversationId] = conversations[index].groupId } - conversations[index].groupId = groupId + var conversation = conversations[index] + conversation.groupId = groupId if let groupId { // Place at the end of the target group by assigning max + 1. let maxOrder = conversations .filter { $0.groupId == groupId && $0.id != conversationId } .compactMap(\.displayOrder).max() ?? -1 - conversations[index].displayOrder = maxOrder + 1 + conversation.displayOrder = maxOrder + 1 } else { // When ungrouping, clear displayOrder and bump lastInteractedAt so // the conversation appears at the top of the ungrouped list. - conversations[index].displayOrder = nil - conversations[index].lastInteractedAt = Date() + conversation.displayOrder = nil + conversation.lastInteractedAt = Date() } + conversations[index] = conversation sendReorderConversations() } @@ -693,28 +711,31 @@ final class ConversationListStore { var markedIds: [UUID] = [] var conversationIds: [String] = [] var priorStates: [UUID: MarkAllSeenPriorState] = [:] - for idx in conversations.indices { - guard !conversations[idx].isArchived, - conversations[idx].kind != .private, - conversations[idx].hasUnseenLatestAssistantMessage else { continue } - let localId = conversations[idx].id - let conversationId = conversations[idx].conversationId - // Capture prior state before overwriting + // Mutate a local copy to avoid N × didSet → recomputeDerivedProperties + // calls when marking many conversations at once. + var snapshot = conversations + for idx in snapshot.indices { + guard !snapshot[idx].isArchived, + snapshot[idx].kind != .private, + snapshot[idx].hasUnseenLatestAssistantMessage else { continue } + let localId = snapshot[idx].id + let conversationId = snapshot[idx].conversationId priorStates[localId] = MarkAllSeenPriorState( - lastSeenAssistantMessageAt: conversations[idx].lastSeenAssistantMessageAt, + lastSeenAssistantMessageAt: snapshot[idx].lastSeenAssistantMessageAt, conversationId: conversationId, override: conversationId.flatMap { pendingAttentionOverrides[$0] } ) - conversations[idx].hasUnseenLatestAssistantMessage = false + snapshot[idx].hasUnseenLatestAssistantMessage = false markedIds.append(localId) if let conversationId { conversationIds.append(conversationId) pendingAttentionOverrides[conversationId] = .seen( - latestAssistantMessageAt: conversations[idx].latestAssistantMessageAt + latestAssistantMessageAt: snapshot[idx].latestAssistantMessageAt ) - conversations[idx].lastSeenAssistantMessageAt = conversations[idx].latestAssistantMessageAt + snapshot[idx].lastSeenAssistantMessageAt = snapshot[idx].latestAssistantMessageAt } } + conversations = snapshot markAllSeenPriorStates = priorStates if !conversationIds.isEmpty { pendingSeenConversationIds = conversationIds @@ -767,11 +788,13 @@ final class ConversationListStore { cancelPendingSeenSignals() let priorStates = markAllSeenPriorStates markAllSeenPriorStates = [:] + // Mutate a local copy to avoid N × didSet → recomputeDerivedProperties. + var snapshot = conversations for id in conversationIds { - if let idx = conversations.firstIndex(where: { $0.id == id }) { - conversations[idx].hasUnseenLatestAssistantMessage = true + if let idx = snapshot.firstIndex(where: { $0.id == id }) { + snapshot[idx].hasUnseenLatestAssistantMessage = true if let prior = priorStates[id] { - conversations[idx].lastSeenAssistantMessageAt = prior.lastSeenAssistantMessageAt + snapshot[idx].lastSeenAssistantMessageAt = prior.lastSeenAssistantMessageAt if let conversationId = prior.conversationId { // Only restore the override if the current override is // still the .seen that markAllConversationsSeen() installed. @@ -789,13 +812,14 @@ final class ConversationListStore { } else { // Fallback: no prior state captured (shouldn't happen in // normal flow), clear conservatively. - conversations[idx].lastSeenAssistantMessageAt = nil - if let conversationId = conversations[idx].conversationId { + snapshot[idx].lastSeenAssistantMessageAt = nil + if let conversationId = snapshot[idx].conversationId { pendingAttentionOverrides.removeValue(forKey: conversationId) } } } } + conversations = snapshot } // MARK: - Attention Merge @@ -804,66 +828,65 @@ final class ConversationListStore { from item: ConversationListResponseItem, intoConversationAt index: Int ) { - conversations[index].hasUnseenLatestAssistantMessage = + // Copy-modify-writeback: mutate a local copy and write back once to + // trigger a single conversations.didSet (and one recomputeDerivedProperties) + // instead of one per field assignment. + var conversation = conversations[index] + conversation.hasUnseenLatestAssistantMessage = item.assistantAttention?.hasUnseenLatestAssistantMessage ?? false - conversations[index].latestAssistantMessageAt = + conversation.latestAssistantMessageAt = item.assistantAttention?.latestAssistantMessageAt.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) } - conversations[index].lastSeenAssistantMessageAt = + conversation.lastSeenAssistantMessageAt = item.assistantAttention?.lastSeenAssistantMessageAt.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) } - guard let conversationId = conversations[index].conversationId, - let override = pendingAttentionOverrides[conversationId] else { return } - - switch override { - case .seen(let targetLatestAssistantMessageAt): - if !conversations[index].hasUnseenLatestAssistantMessage { - pendingAttentionOverrides.removeValue(forKey: conversationId) - return - } - // When target is nil (e.g. notification-created conversation before history loads), - // drop the override if the server reports unseen — the server has newer info. - if targetLatestAssistantMessageAt == nil { - pendingAttentionOverrides.removeValue(forKey: conversationId) - return - } - if let targetLatestAssistantMessageAt, - let serverLatestAssistantMessageAt = conversations[index].latestAssistantMessageAt, - serverLatestAssistantMessageAt > targetLatestAssistantMessageAt { - pendingAttentionOverrides.removeValue(forKey: conversationId) - return - } - - if let targetLatestAssistantMessageAt, - conversations[index].latestAssistantMessageAt == nil { - conversations[index].latestAssistantMessageAt = targetLatestAssistantMessageAt - } - conversations[index].hasUnseenLatestAssistantMessage = false - conversations[index].lastSeenAssistantMessageAt = - conversations[index].latestAssistantMessageAt - - case .unread(let targetLatestAssistantMessageAt): - if conversations[index].hasUnseenLatestAssistantMessage { - pendingAttentionOverrides.removeValue(forKey: conversationId) - return - } - if let targetLatestAssistantMessageAt, - let serverLatestAssistantMessageAt = conversations[index].latestAssistantMessageAt, - serverLatestAssistantMessageAt > targetLatestAssistantMessageAt { - pendingAttentionOverrides.removeValue(forKey: conversationId) - return - } + if let conversationId = conversation.conversationId, + let override = pendingAttentionOverrides[conversationId] { + + switch override { + case .seen(let targetLatestAssistantMessageAt): + if !conversation.hasUnseenLatestAssistantMessage { + pendingAttentionOverrides.removeValue(forKey: conversationId) + } else if targetLatestAssistantMessageAt == nil { + // When target is nil (e.g. notification-created conversation before history loads), + // drop the override if the server reports unseen — the server has newer info. + pendingAttentionOverrides.removeValue(forKey: conversationId) + } else if let targetLatestAssistantMessageAt, + let serverLatestAssistantMessageAt = conversation.latestAssistantMessageAt, + serverLatestAssistantMessageAt > targetLatestAssistantMessageAt { + pendingAttentionOverrides.removeValue(forKey: conversationId) + } else { + if let targetLatestAssistantMessageAt, + conversation.latestAssistantMessageAt == nil { + conversation.latestAssistantMessageAt = targetLatestAssistantMessageAt + } + conversation.hasUnseenLatestAssistantMessage = false + conversation.lastSeenAssistantMessageAt = + conversation.latestAssistantMessageAt + } - if let targetLatestAssistantMessageAt, - conversations[index].latestAssistantMessageAt == nil { - conversations[index].latestAssistantMessageAt = targetLatestAssistantMessageAt + case .unread(let targetLatestAssistantMessageAt): + if conversation.hasUnseenLatestAssistantMessage { + pendingAttentionOverrides.removeValue(forKey: conversationId) + } else if let targetLatestAssistantMessageAt, + let serverLatestAssistantMessageAt = conversation.latestAssistantMessageAt, + serverLatestAssistantMessageAt > targetLatestAssistantMessageAt { + pendingAttentionOverrides.removeValue(forKey: conversationId) + } else { + if let targetLatestAssistantMessageAt, + conversation.latestAssistantMessageAt == nil { + conversation.latestAssistantMessageAt = targetLatestAssistantMessageAt + } + conversation.hasUnseenLatestAssistantMessage = true + conversation.lastSeenAssistantMessageAt = nil + } } - conversations[index].hasUnseenLatestAssistantMessage = true - conversations[index].lastSeenAssistantMessageAt = nil } + + conversations[index] = conversation } // MARK: - Signal Emission @@ -916,8 +939,11 @@ final class ConversationListStore { } else { pendingAttentionOverrides.removeValue(forKey: daemonConversationId) } - conversations[idx].hasUnseenLatestAssistantMessage = false - conversations[idx].lastSeenAssistantMessageAt = previousLastSeenAssistantMessageAt + // Copy-modify-writeback to trigger a single didSet. + var conversation = conversations[idx] + conversation.hasUnseenLatestAssistantMessage = false + conversation.lastSeenAssistantMessageAt = previousLastSeenAssistantMessageAt + conversations[idx] = conversation if wasPendingSeen && !pendingSeenConversationIds.contains(daemonConversationId) { pendingSeenConversationIds.append(daemonConversationId) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift index 69e55b9bcc7..7abed4d1e4f 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift @@ -109,9 +109,12 @@ final class ConversationManager: ConversationRestorerDelegate { selectionStore.restoreRecentConversations } + var lastActiveConversationIdString: String? { + selectionStore.lastActiveConversationIdString + } + var activeConversationId: UUID? { - get { selectionStore.activeConversationId } - set { selectionStore.activeConversationId = newValue } + selectionStore.activeConversationId } var draftViewModel: ChatViewModel? { @@ -303,7 +306,7 @@ final class ConversationManager: ConversationRestorerDelegate { vm.prepareForChannelRefresh() } - selectionStore.activeConversationId = id + selectionStore.performActivation(for: id) // Emit explicit seen signal for user-initiated conversation activation. // Skip during conversation restoration to avoid false "seen" signals on bootstrap. @@ -472,7 +475,7 @@ final class ConversationManager: ConversationRestorerDelegate { self?.promoteDraft(fromUserSend: true) } selectionStore.draftViewModel = viewModel - selectionStore.activeConversationId = nil + selectionStore.performDeactivation() activityStore.observeActiveViewModel(viewModel.messageManager) log.info("Entered draft mode") } @@ -508,7 +511,7 @@ final class ConversationManager: ConversationRestorerDelegate { self?.listStore.updateLastInteracted(conversationId: localId) } - selectionStore.activeConversationId = conversation.id + selectionStore.performActivation(for: conversation.id) listStore.updateLastInteracted(conversationId: conversation.id) log.info("Promoted draft to conversation \(conversation.id)") } @@ -532,7 +535,7 @@ final class ConversationManager: ConversationRestorerDelegate { activityStore.observeInteractionState(for: conversation.id, messageManager: viewModel.messageManager, errorManager: viewModel.errorManager) selectionStore.touchVMAccessOrder(conversation.id) selectionStore.scheduleEvictionIfNeeded() - selectionStore.activeConversationId = conversation.id + selectionStore.performActivation(for: conversation.id) viewModel.createConversationIfNeeded(conversationType: "private") log.info("Created private conversation \(conversation.id)") } @@ -631,9 +634,11 @@ final class ConversationManager: ConversationRestorerDelegate { ConversationSelectionStore.clearRenderCaches() if selectionStore.activeConversationId == id { if index < listStore.conversations.count { - selectionStore.activeConversationId = listStore.conversations[index].id + selectionStore.performActivation(for: listStore.conversations[index].id) + } else if let lastId = listStore.conversations.last?.id { + selectionStore.performActivation(for: lastId) } else { - selectionStore.activeConversationId = listStore.conversations.last?.id + selectionStore.performDeactivation() } } log.info("Closed conversation \(id)") @@ -672,12 +677,12 @@ final class ConversationManager: ConversationRestorerDelegate { if listStore.visibleConversations.isEmpty { enterDraftMode() } else if selectionStore.activeConversationId == id { - let visibleAfter = listStore.conversations[index...].dropFirst().first(where: { !$0.isArchived }) - let visibleBefore = listStore.conversations[.. first visible restored > new conversation. + let activationTarget: UUID? = { + // Try the user's last active conversation first. + if delegate.restoreRecentConversations, + let savedString = delegate.lastActiveConversationIdString, + let savedUUID = UUID(uuidString: savedString), + delegate.conversations.contains(where: { $0.id == savedUUID && !$0.isArchived }) { + return savedUUID + } + // Fall back to the first visible restored conversation. + if let firstVisible = restoredConversations.first(where: { !$0.isArchived }) { + return firstVisible.id + } + return nil + }() + + if let target = activationTarget { + delegate.activateConversation(target) } else if defaultConversationIsEmpty { // All restored conversations are archived and the default conversation was removed, // so create a new empty conversation to avoid a blank window. delegate.createConversation() } - if let hasMore = response.hasMore { - delegate.hasMoreConversations = hasMore - } // serverOffset is set by fetchConversationList before merging foreground + // background, so it reflects foreground-only count for correct pagination. log.info("Restored \(restoredConversations.count) conversations from daemon (hasMore: \(response.hasMore ?? false))") diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift index abc3c9d9cdd..8a54df4f514 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift @@ -25,35 +25,17 @@ final class ConversationSelectionStore { // MARK: - Selection State - /// The currently active conversation's local ID. Setting this triggers - /// ViewModel creation, history loading, and daemon notification. - var activeConversationId: UUID? { + /// The currently active conversation's local ID. + /// + /// The `didSet` performs only lightweight bookkeeping (UserDefaults persistence, + /// stale anchor clearing). Heavy side effects live in ``performActivation(for:)`` + /// and ``performDeactivation()`` — callers must invoke those explicitly. + private(set) var activeConversationId: UUID? { didSet { - if let activeConversationId { - // Switching to a real conversation discards any draft - draftViewModel = nil - - let activeViewModel = getOrCreateViewModel(for: activeConversationId) - activeViewModel?.ensureMessageLoopStarted() - onActiveConversationChanged?(activeConversationId) - // Only persist the active conversation ID if we're not in the middle of restoration. - if !isRestoringConversations { - lastActiveConversationIdString = activeConversationId.uuidString - } - // Notify the daemon so it rebinds the socket to this conversation. - if let conversationId = activeViewModel?.conversationId { - Task { - let success = await listStore.conversationListClient.switchConversation(conversationId: conversationId) - if !success { - log.error("Failed to send conversation switch request") - } - } - } - } else { - // Only clear the persisted conversation ID outside of restoration. - if !isRestoringConversations { - lastActiveConversationIdString = nil - } + // Persist selection (skip during restoration to avoid overwriting the + // saved value before restoreLastActiveConversation reads it). + if !isRestoringConversations { + lastActiveConversationIdString = activeConversationId?.uuidString } // Clear stale anchor when switching away from the conversation that // owns it — prevents the anchor from suppressing scroll-to-bottom @@ -62,16 +44,47 @@ final class ConversationSelectionStore { pendingAnchorMessageId = nil pendingAnchorConversationId = nil } - // Observe the new active view model's message count via the @Observable store. - onActiveViewModelChanged?(activeViewModel?.messageManager) + } + } - // Manage periodic refresh polling for channel conversations. - if let activeConversationId { - startChannelRefreshIfNeeded(conversationId: activeConversationId) - } else { - stopChannelRefresh() + /// Activate a conversation: set ``activeConversationId``, create/retrieve the VM, + /// start the message loop, notify the daemon, and set up observation. + /// + /// Canonical entry point for switching to a conversation. Use + /// ``performDeactivation()`` to clear the selection instead. + func performActivation(for conversationId: UUID) { + guard conversationId != activeConversationId else { return } + // Switching to a real conversation discards any draft. + draftViewModel = nil + activeConversationId = conversationId + + let vm = getOrCreateViewModel(for: conversationId) + vm?.ensureMessageLoopStarted() + onActiveConversationChanged?(conversationId) + + // Notify the daemon so it rebinds the socket to this conversation. + if let serverConversationId = vm?.conversationId { + Task { + let success = await listStore.conversationListClient.switchConversation(conversationId: serverConversationId) + if !success { + log.error("Failed to send conversation switch request") + } } } + + // Observe the new active view model's message count via the @Observable store. + onActiveViewModelChanged?(activeViewModel?.messageManager) + + // Manage periodic refresh polling for channel conversations. + startChannelRefreshIfNeeded(conversationId: conversationId) + } + + /// Deactivate selection (e.g. entering draft mode): clear `activeConversationId`, + /// stop channel refresh, and notify observation. + func performDeactivation() { + activeConversationId = nil + onActiveViewModelChanged?(nil) + stopChannelRefresh() } // MARK: - Draft Mode @@ -346,8 +359,10 @@ final class ConversationSelectionStore { // MARK: - Restoration /// Restore the last active conversation from UserDefaults after conversation restoration completes. + /// + /// If `handleConversationListResponse` already activated the correct conversation, + /// this is a no-op — `activeConversationId` already matches the saved UUID. func restoreLastActiveConversation() { - // After restoration finishes, re-run the active-conversation seen check. defer { onRestorationComplete?() } guard restoreRecentConversations else { @@ -360,10 +375,15 @@ final class ConversationSelectionStore { return } - // Only restore if conversation exists and is visible (not archived) + // Only restore if conversation exists and is visible (not archived). + // Skip when already active to avoid redundant activation side effects. if listStore.conversations.contains(where: { $0.id == savedUUID && !$0.isArchived }) { - activeConversationId = savedUUID - log.info("Restored last active conversation: \(savedUUID)") + if activeConversationId != savedUUID { + performActivation(for: savedUUID) + log.info("Restored last active conversation: \(savedUUID)") + } else { + log.info("Last active conversation \(savedUUID) already active, skipping") + } } else { lastActiveConversationIdString = nil log.info("Saved conversation not found, falling back to default") diff --git a/clients/macos/vellum-assistantTests/ConversationRestorerTests.swift b/clients/macos/vellum-assistantTests/ConversationRestorerTests.swift index 64ec2f6f25b..41f9ba2b42e 100644 --- a/clients/macos/vellum-assistantTests/ConversationRestorerTests.swift +++ b/clients/macos/vellum-assistantTests/ConversationRestorerTests.swift @@ -11,6 +11,7 @@ final class MockConversationRestorerDelegate: ConversationRestorerDelegate { var groups: [ConversationGroup] = [] var daemonSupportsGroups: Bool = false var restoreRecentConversations: Bool = true + var lastActiveConversationIdString: String? var isLoadingMoreConversations: Bool = false var hasMoreConversations: Bool = false var serverOffset: Int = 0