Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)")
}
Expand All @@ -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)")
}
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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[..<index].last(where: { !$0.isArchived })
let visibleAfter = listStore.conversations[index...].dropFirst().first(where: { !$0.isArchived && $0.kind != .private })
let visibleBefore = listStore.conversations[..<index].last(where: { !$0.isArchived && $0.kind != .private })
if let next = visibleAfter ?? visibleBefore {
selectionStore.activeConversationId = next.id
} else {
selectionStore.activeConversationId = listStore.visibleConversations.first?.id
selectionStore.performActivation(for: next.id)
} else if let firstVisibleId = listStore.visibleConversations.first?.id {
selectionStore.performActivation(for: firstVisibleId)
}
}

Expand Down Expand Up @@ -723,7 +728,7 @@ final class ConversationManager: ConversationRestorerDelegate {
vm.prepareForChannelRefresh()
}

selectionStore.activeConversationId = id
selectionStore.performActivation(for: id)

if id != previousActiveId {
listStore.markConversationSeen(conversationId: id)
Expand Down Expand Up @@ -935,7 +940,9 @@ final class ConversationManager: ConversationRestorerDelegate {
if listStore.visibleConversations.isEmpty {
enterDraftMode()
} else {
selectionStore.activeConversationId = listStore.visibleConversations.first?.id
if let firstVisibleId = listStore.visibleConversations.first?.id {
selectionStore.performActivation(for: firstVisibleId)
}
}
} else if listStore.visibleConversations.isEmpty {
enterDraftMode()
Expand Down Expand Up @@ -1090,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)
Expand Down Expand Up @@ -1263,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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Combine
import Foundation
import SwiftUI
import VellumAssistantShared
import os

Expand All @@ -13,6 +14,8 @@ protocol ConversationRestorerDelegate: AnyObject {
var groups: [ConversationGroup] { get set }
var daemonSupportsGroups: Bool { get set }
var restoreRecentConversations: Bool { get }
/// The persisted last-active conversation UUID string (UserDefaults-backed).
var lastActiveConversationIdString: String? { get }
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
var isLoadingMoreConversations: Bool { get set }
var hasMoreConversations: Bool { get set }
var serverOffset: Int { get set }
Expand Down Expand Up @@ -206,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
Expand Down Expand Up @@ -254,9 +265,16 @@ 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
// 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
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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
Expand Down Expand Up @@ -298,26 +316,51 @@ final class ConversationRestorer {
restoredConversations.append(conversation)
}

if defaultConversationIsEmpty {
if let defaultConversation = delegate.conversations.first {
delegate.removeChatViewModel(for: defaultConversation.id)
// Suppress animations during bulk list assignment. Without this,
// SwiftUI computes before/after diffing and animation interpolation
// for every row — expensive when restoring ~50 conversations at once.
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
if defaultConversationIsEmpty {
if let defaultConversation = delegate.conversations.first {
delegate.removeChatViewModel(for: defaultConversation.id)
}
delegate.conversations = restoredConversations
} else {
delegate.conversations = restoredConversations + delegate.conversations
}
delegate.conversations = restoredConversations
} else {
delegate.conversations = restoredConversations + delegate.conversations
}

if let firstVisible = restoredConversations.first(where: { !$0.isArchived }) {
delegate.activateConversation(firstVisible.id)
if let hasMore = response.hasMore {
delegate.hasMoreConversations = hasMore
}

// Determine the activation target once up front.
// Priority: saved last-active > 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))")
Expand Down
Loading