import AgentKit import AppKit import ListViewKit import Observation import SwiftUI import TermindApplication import TermindPresentation typealias TranscriptEntry = AssistantTranscriptEntry @MainActor @Observable final class TranscriptPresentationState { let disclosures = AssistantTranscriptPresentationState() private struct MessageEditState { var isEditing = false var text = "" var bubbleWidth: CGFloat = 0 var revision: UInt64 = 0 } private var messageEdits: [UUID: MessageEditState] = [:] private var nextRevision: UInt64 = 1 func prune(validTimelineIDs: Set) { disclosures.prune(validTimelineIDs: validTimelineIDs) for id in messageEdits.keys where !validTimelineIDs.contains(id) { messageEdits[id] = nil } } func isEditingMessage(_ id: UUID) -> Bool { messageEdits[id]?.isEditing == true } func messageEditText(_ id: UUID) -> String { messageEdits[id]?.text ?? "" } func messageBubbleWidth(_ id: UUID) -> CGFloat { messageEdits[id]?.bubbleWidth ?? 0 } func beginEditingMessage(_ id: UUID, text: String) { var state = messageEdits[id] ?? .init() state.isEditing = true state.text = text state.revision = takeRevision() messageEdits[id] = state } func endEditingMessage(_ id: UUID) { var state = messageEdits[id] ?? .init() state.isEditing = false state.revision = takeRevision() messageEdits[id] = state } func setMessageEditText(_ text: String, for id: UUID) { var state = messageEdits[id] ?? .init() state.text = text state.revision = takeRevision() messageEdits[id] = state } func setMessageBubbleWidth(_ width: CGFloat, for id: UUID) { var state = messageEdits[id] ?? .init() guard abs(state.bubbleWidth - width) > 0.5 else { return } state.bubbleWidth = width state.revision = takeRevision() messageEdits[id] = state } func layoutRevision(for ids: Set) -> UInt64 { ids.reduce(disclosures.layoutRevision(for: ids)) { result, id in result &+ (messageEdits[id]?.revision ?? 0) } } private func takeRevision() -> UInt64 { defer { nextRevision &+= 1 } return nextRevision } } nonisolated struct TranscriptListUpdatePlan: Equatable, Sendable { var replacesAll = false var updates: [TranscriptEntry] = [] var appends: [TranscriptEntry] = [] static func make(old: [TranscriptEntry], new: [TranscriptEntry]) -> Self { guard !old.isEmpty else { return .init(replacesAll: true) } let count = min(old.count, new.count) guard old.prefix(count).map(\.id) == new.prefix(count).map(\.id), new.count >= old.count else { return .init(replacesAll: true) } return .init( updates: zip(old, new).compactMap { $0 == $1 ? nil : $1 }, appends: Array(new.dropFirst(old.count)) ) } } @MainActor struct TranscriptListConfiguration: Equatable { let isCompact: Bool let showsTerminalActions: Bool let isConversationStreaming: Bool let locale: Locale let reduceMotion: Bool let colorScheme: ColorScheme var horizontalInset: CGFloat { isCompact ? Metrics.Space.l : Metrics.Space.xl } } @MainActor struct TranscriptListActions { var hostName: (UUID) -> String? = { _ in nil } var editUserMessage: (UUID, String) -> Void = { _, _ in } var forkAssistantMessage: (UUID) -> Void = { _ in } var resolvePlan: (UUID, AgentPlan.Outcome) -> Void = { _, _ in } var implementPlan: (AgentPlanCard) -> Void = { _ in } var canDeliverCommand: () -> Bool = { false } var deliverCommand: (String, Bool) -> Void = { _, _ in } } /// The two places that may render one conversation at the same time. /// /// AppKit views have one superview, so full-screen Assistant and the Workspace /// sidebar must never share a cached list even when they show the same chat. nonisolated enum TranscriptSurface: Hashable, Sendable { case fullScreen case sidebar } nonisolated struct TranscriptCacheKey: Hashable, Sendable { let conversationID: UUID let surface: TranscriptSurface } /// One long-lived native transcript and the presentation state measured with it. /// /// SwiftUI owns only the short-lived mount container. The owner lives in the /// window cache, so removing Assistant from the destination tree preserves the /// list's content offset, measured row heights, and Markdown cache without /// asking a hidden page to keep participating in layout. @MainActor final class PersistentTranscriptListOwner { let key: TranscriptCacheKey let coordinator = TranscriptListView.Coordinator() let presentation = TranscriptPresentationState() let listView: ListView private weak var container: TranscriptMountView? private var mountConstraints: [NSLayoutConstraint] = [] private var nextMountOrdinal: UInt64 = 0 private let didAccess: () -> Void private let didUnmount: () -> Void private var ownerIdentifier: ObjectIdentifier { ObjectIdentifier(self) } var isMounted: Bool { container != nil && listView.superview === container } init( key: TranscriptCacheKey, didAccess: @escaping () -> Void = {}, didUnmount: @escaping () -> Void = {} ) { self.key = key self.didAccess = didAccess self.didUnmount = didUnmount listView = coordinator.makeListView() listView.translatesAutoresizingMaskIntoConstraints = false } func mount(in newContainer: TranscriptMountView) { assignOrdinalIfNeeded(to: newContainer) if let currentOrdinal = container?.mountOrdinals[ownerIdentifier], let proposedOrdinal = newContainer.mountOrdinals[ownerIdentifier], proposedOrdinal < currentOrdinal { return } didAccess() guard container !== newContainer || listView.superview !== newContainer else { return } if let displacedOwner = newContainer.owner, displacedOwner !== self { displacedOwner.unmount(from: newContainer) } NSLayoutConstraint.deactivate(mountConstraints) mountConstraints.removeAll(keepingCapacity: true) listView.removeFromSuperview() container?.owner = nil newContainer.owner = self newContainer.addSubview(listView) mountConstraints = [ listView.leadingAnchor.constraint(equalTo: newContainer.leadingAnchor), listView.trailingAnchor.constraint(equalTo: newContainer.trailingAnchor), listView.topAnchor.constraint(equalTo: newContainer.topAnchor), listView.bottomAnchor.constraint(equalTo: newContainer.bottomAnchor), ] NSLayoutConstraint.activate(mountConstraints) container = newContainer } func unmount(from oldContainer: TranscriptMountView) { guard container === oldContainer, listView.superview === oldContainer else { return } NSLayoutConstraint.deactivate(mountConstraints) mountConstraints.removeAll(keepingCapacity: true) listView.removeFromSuperview() oldContainer.owner = nil container = nil coordinator.suspend() didUnmount() } func update( entries: [TranscriptEntry], latestUserMessageID: UUID?, configuration: TranscriptListConfiguration, actions: TranscriptListActions ) { coordinator.update( listView: listView, entries: entries, latestUserMessageID: latestUserMessageID, configuration: configuration, actions: actions, presentation: presentation ) } private func assignOrdinalIfNeeded(to container: TranscriptMountView) { guard container.mountOrdinals[ownerIdentifier] == nil else { return } nextMountOrdinal &+= 1 container.mountOrdinals[ownerIdentifier] = nextMountOrdinal } } @MainActor final class TranscriptMountView: NSView { weak var owner: PersistentTranscriptListOwner? fileprivate var mountOrdinals: [ObjectIdentifier: UInt64] = [:] } /// Window-local LRU of native transcript views. /// /// FlowDown keeps one list per conversation. Vela additionally distinguishes /// full-screen and sidebar surfaces and caps the combined cache, because each /// transcript carries Markdown and row-sizing caches of its own. @MainActor final class TranscriptListCache { static let defaultLimit = 8 private let limit: Int private var owners: [TranscriptCacheKey: PersistentTranscriptListOwner] = [:] private var order: [TranscriptCacheKey] = [] init(limit: Int = defaultLimit) { self.limit = max(1, limit) } func owner(for key: TranscriptCacheKey) -> PersistentTranscriptListOwner { if let owner = owners[key] { touch(key) return owner } let owner = PersistentTranscriptListOwner( key: key, didAccess: { [weak self] in self?.touch(key) }, didUnmount: { [weak self] in self?.trimIfNeeded() } ) owners[key] = owner touch(key) trimIfNeeded(protecting: key) return owner } func removeConversation(_ id: UUID) { let keys = owners.keys.filter { $0.conversationID == id } guard !keys.isEmpty else { return } let removed = Set(keys) for key in keys { owners[key] = nil } order.removeAll { removed.contains($0) } } func removeAll() { owners.removeAll() order.removeAll() } func cachedOwner(for key: TranscriptCacheKey) -> PersistentTranscriptListOwner? { owners[key] } var count: Int { owners.count } private func touch(_ key: TranscriptCacheKey) { guard owners[key] != nil else { return } order.removeAll { $0 == key } order.append(key) } private func trimIfNeeded(protecting protectedKey: TranscriptCacheKey? = nil) { while owners.count > limit { guard let victim = order.first(where: { key in key != protectedKey && owners[key]?.isMounted == false }) else { return } owners[victim] = nil order.removeAll { $0 == victim } } } } struct TranscriptListView: NSViewRepresentable { typealias NSViewType = TranscriptMountView typealias NativeListView = ListView let owner: PersistentTranscriptListOwner let entries: [TranscriptEntry] let latestUserMessageID: UUID? let configuration: TranscriptListConfiguration let actions: TranscriptListActions func makeCoordinator() -> Coordinator { Coordinator(mountOwner: owner) } func makeNSView(context: Context) -> NSViewType { let container = TranscriptMountView() context.coordinator.mountOwner?.mount(in: container) return container } func updateNSView(_ view: NSViewType, context: Context) { context.coordinator.mountOwner?.mount(in: view) context.coordinator.mountOwner?.update( entries: entries, latestUserMessageID: latestUserMessageID, configuration: configuration, actions: actions ) } static func dismantleNSView(_ view: NSViewType, coordinator: Coordinator) { coordinator.mountOwner?.unmount(from: view) } @MainActor final class Coordinator: NSObject { fileprivate let mountOwner: PersistentTranscriptListOwner? private var content: [TranscriptEntry] = [] private var latestUserMessageID: UUID? private var configuration: TranscriptListConfiguration? private var actions = TranscriptListActions() private weak var presentation: TranscriptPresentationState? private weak var listView: NativeListView? let markdownCache = TranscriptMarkdownCache(limit: 48) let sizingPool = TranscriptRowSizingPool(limit: 12) private var invalidations: Set = [] private var invalidationScheduled = false private var invalidationMayFollow = false private var autoCollapseCheckScheduled = false private var widthRefreshScheduled = false private var displayedWidth: CGFloat? init(mountOwner: PersistentTranscriptListOwner? = nil) { self.mountOwner = mountOwner super.init() } deinit { NotificationCenter.default.removeObserver(self) } func makeListView() -> NativeListView { let view = NativeListView() listView = view view.estimatedRowHeight = 96 view.topInset = Metrics.Space.xl view.bottomInset = Metrics.Space.xl view.rowAnimator = nil view.postsBoundsChangedNotifications = true view.postsFrameChangedNotifications = true NotificationCenter.default.addObserver( self, selector: #selector(viewportDidChange), name: NSView.boundsDidChangeNotification, object: view ) NotificationCenter.default.addObserver( self, selector: #selector(viewportDidChange), name: NSView.frameDidChangeNotification, object: view ) view.rows { ListRow(TranscriptEmptyRow.self) .when { $0.kind == .empty } .estimatedHeight(96) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 96 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptUserRow.self) .when { $0.kind == .user } .estimatedHeight(96) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 96 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptAssistantRow.self) .when { $0.kind == .assistant } .estimatedHeight(96) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 96 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptToolRow.self) .when { $0.kind == .tool } .estimatedHeight(48) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 48 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptWorkRow.self) .when { $0.kind == .work } .estimatedHeight(72) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 72 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptCommandRow.self) .when { $0.kind == .command } .estimatedHeight(160) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 160 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptPlanRow.self) .when { $0.kind == .plan } .estimatedHeight(180) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 180 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptCompactionRow.self) .when { $0.kind == .compaction } .estimatedHeight(32) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 32 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptCompactionActivityRow.self) .when { $0.kind == .compactionActivity } .estimatedHeight(48) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 48 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptFailureRow.self) .when { $0.kind == .failure } .estimatedHeight(48) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 48 } .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } ListRow(TranscriptInterruptedRow.self) .when { $0.kind == .interrupted } .estimatedHeight(96) .height { [weak self] entry, context in self?.height(for: entry, width: context.width) ?? 96 } .configure { [weak self] row, entry, context in self?.configure( row, entry: entry, width: context.width, measurement: false ) } } return view } func update( listView: NativeListView, entries: [TranscriptEntry], latestUserMessageID: UUID?, configuration: TranscriptListConfiguration, actions: TranscriptListActions, presentation: TranscriptPresentationState ) { applyUpdate( listView: listView, entries: entries, latestUserMessageID: latestUserMessageID, configuration: configuration, actions: actions, presentation: presentation ) } /// Cached transcripts keep their data and measurements while detached, /// but surface-owned actions must not retain a window or conversation. func suspend() { actions = .init() } private func applyUpdate( listView: NativeListView, entries: [TranscriptEntry], latestUserMessageID: UUID?, configuration: TranscriptListConfiguration, actions: TranscriptListActions, presentation: TranscriptPresentationState ) { let oldConfiguration = self.configuration self.configuration = configuration self.actions = actions self.presentation = presentation guard content != entries || self.latestUserMessageID != latestUserMessageID || oldConfiguration != configuration else { return } let validPresentationIDs = entries.reduce(into: Set()) { $0.formUnion($1.presentationIDs) } presentation.prune(validTimelineIDs: validPresentationIDs) let validEntryIDs = Set(entries.map(\.id)) markdownCache.prune(validEntryIDs: validEntryIDs) sizingPool.prune(validEntryIDs: validEntryIDs) let first = content.isEmpty let userSent = self.latestUserMessageID != latestUserMessageID && latestUserMessageID != nil let follow = first || userSent || (listView.isScrolledToBottom(tolerance: Metrics.Space.l) && !listView.isUserInteractingWithScroll) if oldConfiguration != configuration { sizingPool.removeAll() listView.reloadData() } prepareWorkCompletionTransitions( from: content, to: entries, in: listView, presentation: presentation ) let plan = TranscriptListUpdatePlan.make(old: content, new: entries) if plan.replacesAll { if !content.isEmpty { sizingPool.removeAll() } listView.apply(entries) } else { for entry in plan.updates { sizingPool.invalidate(entry.id) listView.update(entry) } if !plan.appends.isEmpty { listView.append(contentsOf: plan.appends) } } content = entries self.latestUserMessageID = latestUserMessageID followBottom(listView, if: follow) schedulePendingAutoCollapseCheck() } /// A successful run is allowed to adopt its collapsed default only /// after the row is wholly outside the reader's viewport. Partial /// visibility is still visibility: collapsing then would remove words /// from under the reader and change the height they are looking at. private func prepareWorkCompletionTransitions( from old: [TranscriptEntry], to new: [TranscriptEntry], in view: NativeListView, presentation: TranscriptPresentationState ) { presentation.disclosures.prepareWorkCompletionTransitions( from: old, to: new, visibleIDs: Set(old.filter { rowIntersectsViewport($0.id, in: view) }.map(\.id)) ) } @objc private func viewportDidChange() { scheduleWidthRefresh() schedulePendingAutoCollapseCheck() } private func scheduleWidthRefresh() { guard let view = listView, view.bounds.width > 0, view.bounds.width != displayedWidth, !widthRefreshScheduled else { return } widthRefreshScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } widthRefreshScheduled = false guard let view = listView, view.bounds.width > 0, view.bounds.width != displayedWidth else { return } displayedWidth = view.bounds.width // ListViewKit remeasures heights on resize, but mounted rows // keep their configuration. Refresh those rows without replacing // the list or treating the resize as new conversation content. for entry in content where view.rowView(for: entry.id) != nil { refreshDisplayedRow(entry.id, in: view) } view.layoutSubtreeIfNeeded() } } private func schedulePendingAutoCollapseCheck() { guard !autoCollapseCheckScheduled else { return } autoCollapseCheckScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } autoCollapseCheckScheduled = false collapsePendingWorkOutsideViewport() } } private func collapsePendingWorkOutsideViewport() { guard let view = listView, let presentation else { return } var collapsed: [UUID] = [] for entry in content where presentation.disclosures.hasPendingAutoCollapse(entry.id) { guard !rowIntersectsViewport(entry.id, in: view), presentation.disclosures.completeAutoCollapse(entry.id) else { continue } collapsed.append(entry.id) } guard !collapsed.isEmpty else { return } for id in collapsed { refreshDisplayedRow(id, in: view) } view.layoutSubtreeIfNeeded() } private func rowIntersectsViewport(_ id: UUID, in view: NativeListView) -> Bool { let row = view.rectForRow(with: id) guard !row.isEmpty, view.bounds.width > 0, view.bounds.height > 0 else { return false } let viewport = CGRect(origin: view.contentOffset, size: view.bounds.size) return row.intersects(viewport) } private func height(for entry: TranscriptEntry, width: CGFloat) -> CGFloat { guard let configuration, let presentation else { return 96 } return sizingPool.height( for: entry, width: width, configuration: configuration, presentation: presentation ) { [weak self] row in self?.configure(row, entry: entry, width: width, measurement: true) } } private func configure( _ row: TranscriptNativeRow, entry: TranscriptEntry, width: CGFloat, measurement: Bool ) { guard let configuration, let presentation else { return } row.configure( entry: entry, proposedWidth: width, configuration: configuration, actions: measurement ? .init() : actions, presentation: presentation, markdownCache: markdownCache, measurement: measurement ) { [weak self] id, mayFollow in self?.sizingPool.invalidate(id) self?.invalidate(id, mayFollow: mayFollow) } } private func invalidate(_ id: UUID, mayFollow: Bool) { invalidations.insert(id) invalidationMayFollow = invalidationMayFollow || mayFollow guard !invalidationScheduled else { return } invalidationScheduled = true DispatchQueue.main.async { [weak self] in guard let self else { return } invalidationScheduled = false let ids = invalidations invalidations.removeAll() let view = listView let follow = invalidationMayFollow && view?.isScrolledToBottom(tolerance: Metrics.Space.l) == true && view?.isUserInteractingWithScroll == false invalidationMayFollow = false guard let view else { return } for id in ids { refreshDisplayedRow(id, in: view) } view.layoutSubtreeIfNeeded() followBottom(view, if: follow) } } func refreshDisplayedRow(_ id: UUID, in view: NativeListView) { sizingPool.invalidate(id) if let index = content.firstIndex(where: { $0.id == id }), let row = view.rowView(for: id) as? TranscriptNativeRow { configure( row, entry: content[index], width: view.bounds.width, measurement: false ) } view.invalidateLayout(forRowWith: id) } private func followBottom(_ view: NativeListView, if shouldFollow: Bool) { guard shouldFollow, !view.isUserInteractingWithScroll else { return } view.layoutSubtreeIfNeeded() guard !view.isUserInteractingWithScroll else { return } view.scrollToBottom(animated: false) } } }