Files
Termind/TFM/Features/AI/TranscriptListView.swift
T
2026-09-12 22:39:45 +08:00

680 lines
26 KiB
Swift

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<UUID>) {
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<UUID>) -> 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<TranscriptEntry>
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 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<TranscriptEntry>
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<UUID> = []
private var invalidationScheduled = false
private var invalidationMayFollow = false
private var autoCollapseCheckScheduled = false
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
NotificationCenter.default.addObserver(
self,
selector: #selector(viewportDidChange),
name: NSView.boundsDidChangeNotification,
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(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<UUID>()) {
$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() {
schedulePendingAutoCollapseCheck()
}
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)
}
}
}