From a8abe742cbc27e17bddb5808df8fc6edae874295 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:43:21 +0000 Subject: [PATCH 1/7] Fix startup hang: eliminate double activation, refactor didSet, suppress animations, cache derived properties - Eliminate double activation in ConversationRestorer.handleConversationListResponse(): Compute the single activation target (saved last-active or first visible) up front and activate exactly once, instead of activating firstVisible then potentially re-activating via restoreLastActiveConversation(). - Refactor activeConversationId didSet into explicit performActivation/performDeactivation: The didSet now performs only lightweight bookkeeping (UserDefaults, anchor clearing). Heavy side effects (VM creation, daemon notification, observation setup) moved to performActivation(for:) and performDeactivation() methods that callers invoke explicitly. - Suppress animations during bulk conversation list restoration: Wrap the bulk conversations array assignment in withTransaction with disablesAnimations to skip animation interpolation for ~50 rows on startup. - Cache computed sidebar properties as stored properties in ConversationListStore: Convert groupedConversations, visibleConversations, sortedGroups, unseenVisibleConversationCount, and archivedConversations from computed to stored properties recomputed once per conversations/groups mutation via didSet. Co-Authored-By: ashlee@vellum.ai --- .../App/AppDelegate+Conversations.swift | 2 +- .../App/AppDelegate+InputMonitors.swift | 2 +- .../MainWindow/ConversationListStore.swift | 96 ++++++++++--------- .../MainWindow/ConversationManager.swift | 30 +++--- .../MainWindow/ConversationRestorer.swift | 50 +++++++--- .../ConversationSelectionStore.swift | 95 ++++++++++-------- 6 files changed, 165 insertions(+), 110 deletions(-) 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 dd1b039b414..b08a3c6c281 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,69 @@ 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`. + private func recomputeDerivedProperties() { + 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 diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift index 69e55b9bcc7..1012f3542ff 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift @@ -109,6 +109,10 @@ final class ConversationManager: ConversationRestorerDelegate { selectionStore.restoreRecentConversations } + var lastActiveConversationIdString: String? { + selectionStore.lastActiveConversationIdString + } + var activeConversationId: UUID? { get { selectionStore.activeConversationId } set { selectionStore.activeConversationId = newValue } @@ -303,7 +307,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 +476,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 +512,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 +536,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 +635,9 @@ final class ConversationManager: ConversationRestorerDelegate { ConversationSelectionStore.clearRenderCaches() if selectionStore.activeConversationId == id { if index < listStore.conversations.count { - selectionStore.activeConversationId = listStore.conversations[index].id - } else { - selectionStore.activeConversationId = listStore.conversations.last?.id + selectionStore.performActivation(for: listStore.conversations[index].id) + } else if let lastId = listStore.conversations.last?.id { + selectionStore.performActivation(for: lastId) } } log.info("Closed conversation \(id)") @@ -675,9 +679,9 @@ final class ConversationManager: ConversationRestorerDelegate { 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..80d577e5113 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. + /// 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. 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,46 @@ 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) { + // 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 +358,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 +374,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") From e657a17cdc8bd6c3c4e4ef0df8cc69fce4828455 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 01:53:13 +0000 Subject: [PATCH 2/7] Add lastActiveConversationIdString to MockConversationRestorerDelegate Protocol requirement added in ConversationRestorer.swift for the single-activation-target optimization. The mock needs it to compile. Co-Authored-By: ashlee@vellum.ai --- .../macos/vellum-assistantTests/ConversationRestorerTests.swift | 1 + 1 file changed, 1 insertion(+) 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 From c0abddd09f63b4d63e01611e2f35b8c233a73a53 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:08:30 +0000 Subject: [PATCH 3/7] Fix archiveConversation fallback to exclude private conversations The visibleAfter/visibleBefore filters only checked !isArchived, while visibleConversations also excludes .private conversations. This could cause archiveConversation to activate a private conversation as the next selection target. Now consistent with visibleConversations. Co-Authored-By: ashlee@vellum.ai --- .../Features/MainWindow/ConversationManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift index 1012f3542ff..2b4ac4806fa 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift @@ -676,8 +676,8 @@ 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[.. Date: Fri, 3 Apr 2026 15:20:17 +0000 Subject: [PATCH 4/7] Lock down activeConversationId setter, fix closeConversation deactivation, guard empty recompute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make activeConversationId private(set) on ConversationSelectionStore. Remove the public setter from ConversationManager facade — only performActivation(for:) and performDeactivation() can change selection. Prevents future code from silently bypassing VM creation, daemon notification, and observer wiring. - Add performDeactivation() fallback in closeConversation when both branches fail to find a next conversation, preventing stale activeConversationId. - Guard recomputeDerivedProperties() to skip expensive sort/filter/bucket work when conversations is empty (e.g. when groups is assigned before conversations during restoration). Co-Authored-By: ashlee@vellum.ai --- .../Features/MainWindow/ConversationListStore.swift | 12 +++++++++++- .../Features/MainWindow/ConversationManager.swift | 5 +++-- .../MainWindow/ConversationSelectionStore.swift | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift index 3264baf383d..1ff75a7133a 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift @@ -141,8 +141,18 @@ final class ConversationListStore { private(set) var archivedConversations: [ConversationModel] = [] /// Recompute all derived sidebar properties from `conversations` and `groups`. - /// Called from `conversations.didSet` and `groups.didSet`. + /// 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 diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift index 2b4ac4806fa..e6bc1b6d7b1 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift @@ -114,8 +114,7 @@ final class ConversationManager: ConversationRestorerDelegate { } var activeConversationId: UUID? { - get { selectionStore.activeConversationId } - set { selectionStore.activeConversationId = newValue } + selectionStore.activeConversationId } var draftViewModel: ChatViewModel? { @@ -638,6 +637,8 @@ final class ConversationManager: ConversationRestorerDelegate { selectionStore.performActivation(for: listStore.conversations[index].id) } else if let lastId = listStore.conversations.last?.id { selectionStore.performActivation(for: lastId) + } else { + selectionStore.performDeactivation() } } log.info("Closed conversation \(id)") diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift index 80d577e5113..347bb990e2c 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift @@ -30,7 +30,7 @@ final class ConversationSelectionStore { /// 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. - var activeConversationId: UUID? { + private(set) var activeConversationId: UUID? { didSet { // Persist selection (skip during restoration to avoid overwriting the // saved value before restoreLastActiveConversation reads it). From 241b6375be0027f7ad4123816f898e1e6bd6662a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:17:45 +0000 Subject: [PATCH 5/7] Use copy-modify-writeback to avoid redundant recomputeDerivedProperties calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-field struct mutations on conversations[index] each trigger conversations.didSet → recomputeDerivedProperties() (O(N log N) sort + O(N) filter + O(N) bucketing). With stored derived properties, this is a regression vs the old computed-property approach which deferred work to view body evaluation. Fixed hot paths: - mergeAssistantAttention: 3–6 field writes → 1 writeback - markAllConversationsSeen: N element mutations → 1 snapshot writeback - restoreUnseen: N element mutations → 1 snapshot writeback - rollbackUnreadMutationIfNeeded: 2 field writes → 1 writeback - ConversationRestorer existing-conversation merge: 3 field writes + mergeAssistantAttention (4 total didSet triggers) → 1 writeback with inlined attention fields Co-Authored-By: ashlee@vellum.ai --- .../MainWindow/ConversationListStore.swift | 142 +++++++++--------- .../MainWindow/ConversationRestorer.swift | 22 ++- 2 files changed, 93 insertions(+), 71 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift index 1ff75a7133a..daa94c9c49d 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift @@ -705,28 +705,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 @@ -779,11 +782,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. @@ -801,13 +806,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 @@ -816,66 +822,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 @@ -928,8 +933,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/ConversationRestorer.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift index 3bb888dff5d..2c674ce0094 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift @@ -257,10 +257,24 @@ final class ConversationRestorer { // createNotificationConversation before the session list response arrived), // merge server pin/order metadata into it instead of creating a duplicate. if let existingIdx = delegate.conversations.firstIndex(where: { $0.conversationId == session.id }) { - delegate.conversations[existingIdx].groupId = groupId - delegate.conversations[existingIdx].displayOrder = session.displayOrder.map { Int($0) } - delegate.conversations[existingIdx].forkParent = session.forkParent - delegate.mergeAssistantAttention(from: session, intoConversationAt: existingIdx) + // Copy-modify-writeback: apply all field updates to a local copy + // and write back once, so conversations.didSet (and + // recomputeDerivedProperties) fires only once per existing match. + var existing = delegate.conversations[existingIdx] + existing.groupId = groupId + existing.displayOrder = session.displayOrder.map { Int($0) } + existing.forkParent = session.forkParent + existing.hasUnseenLatestAssistantMessage = + session.assistantAttention?.hasUnseenLatestAssistantMessage ?? false + existing.latestAssistantMessageAt = + session.assistantAttention?.latestAssistantMessageAt.map { + Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) + } + existing.lastSeenAssistantMessageAt = + session.assistantAttention?.lastSeenAssistantMessageAt.map { + Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) + } + delegate.conversations[existingIdx] = existing continue } From d398857e4f3e36482ad28757e44a97de49f0cd4e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:26:52 +0000 Subject: [PATCH 6/7] Restore mergeAssistantAttention call for pendingAttentionOverrides reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit inlined attention field assignments to avoid extra didSet triggers, but this bypassed the pendingAttentionOverrides logic in mergeAssistantAttention. This matters when a notification conversation is created and opened by the user before the session list response arrives — the user's local seen/unread state would be overwritten by stale server data. Now: non-attention fields (groupId, displayOrder, forkParent) use copy-modify-writeback (1 didSet), then mergeAssistantAttention handles attention fields with override reconciliation (1 more didSet). Total: 2 didSet triggers, down from 4 in the original code. Co-Authored-By: ashlee@vellum.ai --- .../MainWindow/ConversationRestorer.swift | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift index 2c674ce0094..befe55b6997 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift @@ -257,24 +257,17 @@ final class ConversationRestorer { // createNotificationConversation before the session list response arrived), // merge server pin/order metadata into it instead of creating a duplicate. if let existingIdx = delegate.conversations.firstIndex(where: { $0.conversationId == session.id }) { - // Copy-modify-writeback: apply all field updates to a local copy - // and write back once, so conversations.didSet (and - // recomputeDerivedProperties) fires only once per existing match. + // Copy-modify-writeback for non-attention fields: write back once + // so conversations.didSet fires once instead of per-field. var existing = delegate.conversations[existingIdx] existing.groupId = groupId existing.displayOrder = session.displayOrder.map { Int($0) } existing.forkParent = session.forkParent - existing.hasUnseenLatestAssistantMessage = - session.assistantAttention?.hasUnseenLatestAssistantMessage ?? false - existing.latestAssistantMessageAt = - session.assistantAttention?.latestAssistantMessageAt.map { - Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) - } - existing.lastSeenAssistantMessageAt = - session.assistantAttention?.lastSeenAssistantMessageAt.map { - Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) - } delegate.conversations[existingIdx] = existing + // Attention merge must go through mergeAssistantAttention so that + // pendingAttentionOverrides are reconciled (e.g. a notification + // conversation the user already opened before the list arrived). + delegate.mergeAssistantAttention(from: session, intoConversationAt: existingIdx) continue } From 27e35d12a5404088cae9e24b935c4c884d89c918 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:56:54 +0000 Subject: [PATCH 7/7] Apply copy-modify-writeback to remaining multi-mutation paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pinConversation: 2 per-field writes → 1 writeback - unpinConversation: 2 per-field writes → 1 writeback - moveConversationToGroup: 2-3 per-field writes → 1 writeback - handleAssistantMessageArrival: up to 3 per-field writes → 1 writeback - handleNotificationIntentForExistingConversation: up to 3 per-field writes → 1 writeback - performActivation(for:): add short-circuit guard when already-active conversation is re-activated, avoiding redundant VM creation, daemon notification, and observation wiring - ConversationRestorer groups assignment: wrap in withTransaction to suppress animations during the groups-only didSet Co-Authored-By: ashlee@vellum.ai --- .../MainWindow/ConversationListStore.swift | 32 +++++++++++-------- .../MainWindow/ConversationManager.swift | 30 ++++++++++------- .../MainWindow/ConversationRestorer.swift | 12 +++++-- .../ConversationSelectionStore.swift | 1 + 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift index daa94c9c49d..3e1a2821b2e 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationListStore.swift @@ -313,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). @@ -330,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() } @@ -351,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() } diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift index e6bc1b6d7b1..7abed4d1e4f 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationManager.swift @@ -1097,13 +1097,15 @@ final class ConversationManager: ConversationRestorerDelegate { func handleNotificationIntentForExistingConversation(daemonConversationId: String) { guard let idx = listStore.conversations.firstIndex(where: { $0.conversationId == daemonConversationId }) else { return } let localId = listStore.conversations[idx].id - listStore.conversations[idx].lastInteractedAt = Date() + var conversation = listStore.conversations[idx] + conversation.lastInteractedAt = Date() if localId != selectionStore.activeConversationId { - listStore.conversations[idx].hasUnseenLatestAssistantMessage = true - listStore.conversations[idx].latestAssistantMessageAt = Date() + conversation.hasUnseenLatestAssistantMessage = true + conversation.latestAssistantMessageAt = Date() listStore.pendingSeenConversationIds.removeAll { $0 == daemonConversationId } } + listStore.conversations[idx] = conversation if let vm = selectionStore.chatViewModels[localId], !vm.isThinking, !vm.isSending { pendingNotificationCatchUpIds.remove(daemonConversationId) @@ -1270,21 +1272,25 @@ final class ConversationManager: ConversationRestorerDelegate { if isNewMessage && !listStore.conversations[index].isBackgroundConversation { listStore.updateLastInteracted(conversationId: conversationId) } - if listStore.conversations[index].latestAssistantMessageAt == nil || isNewMessage { - listStore.conversations[index].latestAssistantMessageAt = Date() + var conversation = listStore.conversations[index] + if conversation.latestAssistantMessageAt == nil || isNewMessage { + conversation.latestAssistantMessageAt = Date() } + var shouldEmitSeenSignal = false if conversationId == selectionStore.activeConversationId { - if listStore.conversations[index].hasUnseenLatestAssistantMessage { - listStore.conversations[index].hasUnseenLatestAssistantMessage = false + if conversation.hasUnseenLatestAssistantMessage { + conversation.hasUnseenLatestAssistantMessage = false } let streamingJustCompleted = previousSnapshot?.isStreaming == true && !currentSnapshot.isStreaming if isNewMessage || streamingJustCompleted { - if let conversationId = listStore.conversations[index].conversationId { - listStore.emitConversationSeenSignal(conversationId: conversationId) - } + shouldEmitSeenSignal = true } - } else if !listStore.conversations[index].hasUnseenLatestAssistantMessage { - listStore.conversations[index].hasUnseenLatestAssistantMessage = true + } else if !conversation.hasUnseenLatestAssistantMessage { + conversation.hasUnseenLatestAssistantMessage = true + } + listStore.conversations[index] = conversation + if shouldEmitSeenSignal, let daemonId = conversation.conversationId { + listStore.emitConversationSeenSignal(conversationId: daemonId) } } diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift index befe55b6997..25e3dc6a449 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationRestorer.swift @@ -209,14 +209,22 @@ final class ConversationRestorer { // Seed groups from the response if available, otherwise fall back to system defaults. // This must run before the restoreRecentConversations guard so that users who // disable restore still get groups initialized for the session. + // Wrapped in an animation-suppressing transaction so SwiftUI doesn't + // compute diffing/animation for the groups-only update. + var groupTransaction = Transaction() + groupTransaction.disablesAnimations = true let daemonSupportsGroups: Bool if let responseGroups = response.groups, !responseGroups.isEmpty { - delegate.groups = responseGroups.map { ConversationGroup(from: $0) } + withTransaction(groupTransaction) { + delegate.groups = responseGroups.map { ConversationGroup(from: $0) } + } delegate.daemonSupportsGroups = true daemonSupportsGroups = true } else { if delegate.groups.isEmpty { - delegate.groups = [ConversationGroup.pinned, ConversationGroup.scheduled, ConversationGroup.background] + withTransaction(groupTransaction) { + delegate.groups = [ConversationGroup.pinned, ConversationGroup.scheduled, ConversationGroup.background] + } } delegate.daemonSupportsGroups = false daemonSupportsGroups = false diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift b/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift index 347bb990e2c..8a54df4f514 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ConversationSelectionStore.swift @@ -53,6 +53,7 @@ final class ConversationSelectionStore { /// 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