diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 3636172a..616eb4c7 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -407,6 +407,9 @@ struct MobileAssistantConversation: View { followsBottom: true, surface: .plain, rowRevision: { revisions[$0.id] ?? 0 }, + interactionRevision: { + Self.transcriptInteractionRevision(for: $0, isStreaming: conversation.isStreaming) + }, prepareUpdate: { old, new, visible in presentation.prepareWorkCompletionTransitions(from: old, to: new, visibleIDs: visible) }, @@ -474,7 +477,16 @@ struct MobileAssistantConversation: View { } private var transcriptRefreshID: String { - "\(conversation.id.uuidString):\(conversation.isArchived):\(conversation.isStreaming)" + Self.transcriptRefreshID(for: conversation) + } + + static func transcriptRefreshID(for conversation: ConversationModel) -> String { + "\(conversation.id.uuidString):\(conversation.isArchived)" + } + + static func transcriptInteractionRevision(for entry: AssistantTranscriptEntry, isStreaming: Bool) -> UInt64 { + guard case .plan = entry.content else { return 0 } + return isStreaming ? 1 : 0 } private var acceptsAttachmentDrops: Bool { diff --git a/TFI/UI/Components/MobileNativeList.swift b/TFI/UI/Components/MobileNativeList.swift index 047adf79..fe0e8cf3 100644 --- a/TFI/UI/Components/MobileNativeList.swift +++ b/TFI/UI/Components/MobileNativeList.swift @@ -62,6 +62,8 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { let bottomContentInset: CGFloat let surface: MobileNativeListSurface let rowRevision: ((Item) -> UInt64)? + /// Refreshes mounted content without invalidating geometry; use rowRevision for layout changes. + let interactionRevision: ((Item) -> UInt64)? let prepareUpdate: (([Item], [Item], Set) -> Void)? let onViewportChange: ((Set) -> Void)? let onTap: (() -> Void)? @@ -82,6 +84,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { bottomContentInset: CGFloat = 0, surface: MobileNativeListSurface = .grouped, rowRevision: ((Item) -> UInt64)? = nil, + interactionRevision: ((Item) -> UInt64)? = nil, prepareUpdate: (([Item], [Item], Set) -> Void)? = nil, onViewportChange: ((Set) -> Void)? = nil, onTap: (() -> Void)? = nil, @@ -96,6 +99,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { self.bottomContentInset = bottomContentInset self.surface = surface self.rowRevision = rowRevision + self.interactionRevision = interactionRevision self.prepareUpdate = prepareUpdate self.onViewportChange = onViewportChange self.onTap = onTap @@ -145,6 +149,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { reduceMotion: reduceMotion || reduceMotionOverride, sceneIsActive: scenePhase == .active, rowRevision: rowRevision, + interactionRevision: interactionRevision, prepareUpdate: prepareUpdate, onViewportChange: onViewportChange, onTap: onTap @@ -173,6 +178,8 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { private var reduceMotion = false private var sceneIsActive = true private var revisions: [Item.ID: UInt64] = [:] + private var interactionRevisions: [Item.ID: UInt64] = [:] + private var configuredIDsDuringUpdate: Set? private var onViewportChange: ((Set) -> Void)? private var onTap: (() -> Void)? private weak var listView: ListView? @@ -231,10 +238,13 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { reduceMotion: Bool = false, sceneIsActive: Bool = true, rowRevision: ((Item) -> UInt64)? = nil, + interactionRevision: ((Item) -> UInt64)? = nil, prepareUpdate: (([Item], [Item], Set) -> Void)? = nil, onViewportChange: ((Set) -> Void)? = nil, onTap: (() -> Void)? = nil ) { + configuredIDsDuringUpdate = interactionRevision == nil ? nil : [] + defer { configuredIDsDuringUpdate = nil } let wasEmpty = items.isEmpty let wasAtBottom = wasEmpty || listView.isScrolledToBottom(tolerance: 48) let environmentChanged = @@ -257,6 +267,10 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { prepareUpdate?(items, newItems, visibleIDs(in: listView)) let newRevisions = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, rowRevision?($0) ?? 0) }) + let newInteractionRevisions = + interactionRevision.map { revision in + Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, revision($0)) }) + } ?? [:] let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) let changed = newItems.filter { previous[$0.id] != $0 || revisions[$0.id] != newRevisions[$0.id] @@ -279,8 +293,21 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { listView.invalidateLayout(forRowWith: item.id) } if environmentChanged { listView.reloadData() } + // Interaction-only updates retain the host and its measured geometry. Reconciliation + // may already have configured a row with the latest renderer, so do not configure twice. + if interactionRevision != nil, !environmentChanged { + for item in newItems + where interactionRevisions[item.id] != newInteractionRevisions[item.id] + && configuredIDsDuringUpdate?.contains(item.id) != true + { + if let row = listView.rowView(for: item.id) as? MobileHostingListRow { + configure(row, item: item, surface: surface) + } + } + } items = newItems revisions = newRevisions + interactionRevisions = newInteractionRevisions scheduleViewportCheck() if followsBottom, wasAtBottom, identitiesChanged || !changed.isEmpty { DispatchQueue.main.async { [weak listView] in @@ -334,6 +361,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { } func configure(_ row: MobileHostingListRow, item: Item, surface: MobileNativeListSurface) { + configuredIDsDuringUpdate?.insert(item.id) row.configure( rootView: rootView(for: item), selectable: onSelect != nil, diff --git a/TFITests/MobileAdaptiveLayoutTests.swift b/TFITests/MobileAdaptiveLayoutTests.swift index 2dac6f92..9f751f37 100644 --- a/TFITests/MobileAdaptiveLayoutTests.swift +++ b/TFITests/MobileAdaptiveLayoutTests.swift @@ -109,6 +109,16 @@ struct MobileAdaptiveLayoutTests { #expect(input.text(in: marked) == "输入中") capture(controller, name: "assistant-fold-\(index)-\(language)") } + for isRunning in [true, false] { + conversation.handle(.runState(isRunning ? .running : .completed)) + try await settle() + #expect(find(ListScrollView.self, in: controller.view) === transcript) + #expect(composer.isFirstResponder) + #expect(composer.window != nil) + let marked = try #require(input.markedTextRange) + #expect(input.text(in: marked) == "输入中") + capture(controller, name: "assistant-run-\(isRunning)-\(language)") + } input.unmarkText() composer.resignFirstResponder() } diff --git a/TFITests/MobileAssistantTranscriptTests.swift b/TFITests/MobileAssistantTranscriptTests.swift index e9a26c2f..6606f4ee 100644 --- a/TFITests/MobileAssistantTranscriptTests.swift +++ b/TFITests/MobileAssistantTranscriptTests.swift @@ -103,6 +103,232 @@ struct MobileAssistantTranscriptTests { } } + @Test func conversationRunBoundariesKeepUnchangedRowsAndHostingRoots() throws { + let conversation = ConversationModel() + let harness = ConversationHarness() + harness.update(conversation) + let entry = try #require(harness.entries.first) + let row = try #require(harness.list.rowView(for: entry.id)) + let hosted = try #require(row.subviews.first?.subviews.first) + + for state: AgentRunState in [.running, .completed] { + harness.renderedIDs.removeAll() + conversation.handle(.runState(state)) + harness.update(conversation) + #expect(harness.entries.first == entry) + #expect(harness.list.rowView(for: entry.id) === row) + #expect(row.subviews.first?.subviews.first === hosted) + #expect(!harness.renderedIDs.contains(entry.id)) + } + } + + @Test func conversationRunBoundariesKeepHistoryAnchorAndHostedIdentity() async throws { + let conversation = ConversationModel(messages: (0..<30).map { message("Message \($0)") }) + let harness = ConversationHarness() + harness.update(conversation) + harness.list.setContentOffset(CGPoint(x: 0, y: 120), animated: false) + harness.list.layoutIfNeeded() + let index = try #require(harness.list.indicesForVisibleRows.first) + let id = harness.entries[index].id + let row = try #require(harness.list.rowView(for: id)) + let hosted = try #require(row.subviews.first?.subviews.first) + let anchor = harness.list.rectForRow(with: id).minY - harness.list.contentOffset.y + + for state: AgentRunState in [.running, .completed] { + conversation.handle(.runState(state)) + harness.update(conversation, followsBottom: true) + try await Task.sleep(for: .milliseconds(30)) + #expect(harness.list.rowView(for: id) === row) + #expect(row.subviews.first?.subviews.first === hosted) + let currentAnchor = harness.list.rectForRow(with: id).minY - harness.list.contentOffset.y + #expect(abs(currentAnchor - anchor) < 1) + } + } + + @Test func interactionOnlyUpdatesKeepHostsMeasurementsAndHistoryAnchor() async throws { + let harness = Harness() + let items = (0..<30).map { message("Message \($0)") } + harness.update(items) + // Let the initial offscreen measurement pass finish before counting invalidations. + for _ in 0..<100 where Set(harness.measuredIDs).count < items.count { + try await Task.sleep(for: .milliseconds(10)) + } + try #require(Set(harness.measuredIDs).count == items.count) + harness.list.setContentOffset(CGPoint(x: 0, y: 120), animated: false) + harness.list.layoutIfNeeded() + let index = try #require(harness.list.indicesForVisibleRows.first) + let id = items[index].id + let row = try #require(harness.list.rowView(for: id)) + let hosted = try #require(row.subviews.first?.subviews.first) + let anchor = harness.list.rectForRow(with: id).minY - harness.list.contentOffset.y + let contentSize = harness.list.contentSize + harness.renderedIDs.removeAll() + harness.measuredIDs.removeAll() + harness.interactionRevisions[id] = 1 + harness.update(items, followsBottom: true) + try await Task.sleep(for: .milliseconds(30)) + + #expect(harness.list.rowView(for: id) === row) + #expect(row.subviews.first?.subviews.first === hosted) + #expect(harness.renderedIDs == [id]) + #expect(harness.measuredIDs.isEmpty) + #expect(harness.list.contentSize == contentSize) + #expect(abs(harness.list.rectForRow(with: id).minY - harness.list.contentOffset.y - anchor) < 1) + } + + @Test func mixedInteractionContentAndDisclosureUpdatesConfigureEachAffectedRowOnce() throws { + let harness = Harness() + var first = message("First") + let second = message("Second") + harness.update([first, second]) + let row = try #require(harness.list.rowView(for: first.id)) + let hosted = try #require(row.subviews.first?.subviews.first) + harness.renderedIDs.removeAll() + harness.interactionRevisions[first.id] = 1 + first.text += " delta" + harness.update([first, second]) + #expect(harness.renderedIDs == [first.id]) + #expect(harness.renderedInteractionRevisions[first.id] == 1) + #expect(row.subviews.first?.subviews.first === hosted) + + harness.renderedIDs.removeAll() + harness.interactionRevisions[first.id] = 2 + harness.revisions[first.id] = 1 + harness.update([first, second]) + #expect(harness.renderedIDs == [first.id]) + #expect(harness.renderedInteractionRevisions[first.id] == 2) + #expect(row.subviews.first?.subviews.first === hosted) + } + + @Test func offscreenMountAndReinsertUseTheLatestInteractionRenderer() throws { + let harness = Harness() + let items = (0..<40).map { message("Message \($0)") } + let tail = try #require(items.last) + harness.update(items) + #expect(harness.list.rowView(for: tail.id) == nil) + harness.renderedIDs.removeAll() + harness.interactionRevisions[tail.id] = 1 + harness.update(items) + #expect(!harness.renderedIDs.contains(tail.id)) + harness.list.scrollToBottom(animated: false) + harness.list.layoutIfNeeded() + _ = try #require(harness.list.rowView(for: tail.id)) + #expect(harness.renderedInteractionRevisions[tail.id] == 1) + + harness.update(Array(items.dropLast())) + harness.interactionRevisions[tail.id] = 2 + harness.update(items) + harness.list.scrollToBottom(animated: false) + harness.list.layoutIfNeeded() + _ = try #require(harness.list.rowView(for: tail.id)) + #expect(harness.renderedInteractionRevisions[tail.id] == 2) + } + + @Test func explicitRefreshAndRealEnvironmentChangesStillRebuildRows() throws { + let harness = Harness() + let item = message("File row") + harness.update([item], tracksInteractions: false) + let original = try #require(harness.list.rowView(for: item.id)) + harness.update([item], refreshID: "directory changed", tracksInteractions: false) + let refreshed = try #require(harness.list.rowView(for: item.id)) + #expect(refreshed !== original) + harness.update([item], refreshID: "directory changed", colorScheme: .dark, tracksInteractions: false) + #expect(harness.list.rowView(for: item.id) !== refreshed) + } + + @Test func conversationAndArchiveChangesStillRebuildTranscriptRows() throws { + let harness = ConversationHarness() + let conversation = ConversationModel() + harness.update(conversation) + let id = try #require(harness.entries.first?.id) + let original = try #require(harness.list.rowView(for: id)) + conversation.archivedAt = .now + harness.update(conversation) + let archived = try #require(harness.list.rowView(for: id)) + #expect(archived !== original) + conversation.archivedAt = nil + harness.update(conversation) + let restored = try #require(harness.list.rowView(for: id)) + #expect(restored !== archived) + harness.update(ConversationModel()) + let switchedID = try #require(harness.entries.first?.id) + #expect(harness.list.rowView(for: switchedID) !== restored) + } + + @Test(.requiresKeyWindow) + func planImplementControlTracksRunBoundariesWithoutReplacingItsHost() async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let previousKeyWindow = scene.windows.first(where: \.isKeyWindow) + let window = UIWindow(windowScene: scene) + let controller = UIViewController() + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + previousKeyWindow?.makeKey() + } + let conversation = ConversationModel() + let plan = AgentPlan(title: "Implementation plan", body: "Apply the change.", scratchPath: "/tmp/plan.md") + conversation.handle(.planPresented(plan)) + let harness = ConversationHarness(rowHeight: 320) + harness.list.frame = CGRect(x: 0, y: 0, width: 390, height: 400) + controller.view.addSubview(harness.list) + harness.update(conversation) + let row = try #require(harness.list.rowView(for: plan.id)) + let hosted = try #require(row.subviews.first?.subviews.first) + var implementLabel = AppStrings.Assistant.implement + implementLabel.locale = Locale(identifier: "en") + let label = String(localized: implementLabel) + + for state: AgentRunState in [.idle, .running, .completed] { + conversation.handle(.runState(state)) + harness.update(conversation) + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + #expect(harness.list.rowView(for: plan.id) === row) + #expect(row.subviews.first?.subviews.first === hosted) + let button = try #require(accessibilityButton(in: row, label: label)) + #expect(button.accessibilityTraits.contains(.notEnabled) == conversation.isStreaming) + if !conversation.isStreaming { + let count = harness.implementedIDs.count + #expect(button.accessibilityActivate()) + #expect(harness.implementedIDs.count == count + 1) + #expect(harness.implementedIDs.last == plan.id) + } + } + + conversation.messages = (0..<30).map { message("Later message \($0)") } + harness.update(conversation) + harness.list.scrollToBottom(animated: false) + harness.list.layoutIfNeeded() + #expect(harness.list.rowView(for: plan.id) == nil) + conversation.handle(.runState(.running)) + harness.update(conversation) + harness.list.setContentOffset(.zero, animated: false) + harness.list.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + let remountedRow = try #require(harness.list.rowView(for: plan.id)) + let remountedButton = try #require(accessibilityButton(in: remountedRow, label: label)) + #expect(remountedButton.accessibilityTraits.contains(.notEnabled)) + + conversation.archivedAt = .now + harness.update(conversation) + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + let archivedRow = try #require(harness.list.rowView(for: plan.id)) + #expect(archivedRow !== remountedRow) + #expect(accessibilityButton(in: archivedRow, label: label) == nil) + conversation.archivedAt = nil + conversation.handle(.runState(.completed)) + harness.update(conversation) + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(50)) + let restoredRow = try #require(harness.list.rowView(for: plan.id)) + let restoredButton = try #require(accessibilityButton(in: restoredRow, label: label)) + #expect(!restoredButton.accessibilityTraits.contains(.notEnabled)) + } + @Test func streamedUpdatesAndDisclosureRevisionsKeepTheHostedView() throws { let harness = Harness() let first = message("First") @@ -356,10 +582,14 @@ struct MobileAssistantTranscriptTests { } private func navigationButton(in root: NSObject) -> NSObject? { + accessibilityButton(in: root, label: "Open conversation") + } + + private func accessibilityButton(in root: NSObject, label: String) -> NSObject? { var seen = Set() func visit(_ element: NSObject) -> NSObject? { guard seen.insert(ObjectIdentifier(element)).inserted else { return nil } - if element.accessibilityTraits.contains(.button), element.accessibilityLabel == "Open conversation" { + if element.accessibilityTraits.contains(.button), element.accessibilityLabel == label { return element } var children: [NSObject] = (element as? UIView)?.subviews ?? [] @@ -367,37 +597,114 @@ struct MobileAssistantTranscriptTests { if count != NSNotFound, count > 0 { children += (0.. ChatMessage { .init(role: .assistant, text: text, isStreaming: true) } - private final class Harness { - let coordinator = MobileNativeList.Coordinator() - let list = ListView() - var revisions: [UUID: UInt64] = [:] + private final class ConversationHarness { + let coordinator = MobileNativeList.Coordinator() + let list = ListView() + private(set) var entries: [AssistantTranscriptEntry] = [] + let presentation = AssistantTranscriptPresentationState() var renderedIDs: [UUID] = [] + var implementedIDs: [UUID] = [] - init() { + init(rowHeight: CGFloat = 80) { list.frame = CGRect(x: 0, y: 0, width: 390, height: 280) + list.estimatedRowHeight = rowHeight list.rows { - ListRow(MobileHostingListRow.self).height { _, _ in 80 }.configure { [weak self] row, item, _ in + ListRow(MobileHostingListRow.self).height { _, _ in rowHeight }.configure { [weak self] row, item, _ in self?.coordinator.configure(row, item: item, surface: .plain) } } } - func update(_ items: [ChatMessage], followsBottom: Bool = false) { + func update(_ conversation: ConversationModel, followsBottom: Bool = false) { + let streaming = conversation.isStreaming + let archived = conversation.isArchived + let availability = conversation.messageActionAvailability + entries = AssistantTranscriptEntry.items( + timeline: conversation.timeline, + isInterrupted: conversation.runState == .interrupted, + canEdit: availability.editable.contains, + canFork: availability.forkable.contains + ) + coordinator.update( + listView: list, items: entries, + renderer: { [weak self] item in + self?.renderedIDs.append(item.id) + switch item.content { + case .assistant(let message, _), .user(let message, _): + return AnyView(Text(message.text)) + case .plan(let card): + guard let self else { return AnyView(EmptyView()) } + return AnyView( + MobileAssistantPlanRow( + card: card, presentation: presentation, readOnly: archived, + isStreaming: streaming, + implement: { [weak self] in self?.implementedIDs.append(card.id) }, discard: {} + ) + ) + default: + return AnyView(Text("Transcript row")) + } + }, onSelect: nil, onRefresh: nil, followsBottom: followsBottom, surface: .plain, + refreshID: MobileAssistantConversation.transcriptRefreshID(for: conversation), + locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, + rowRevision: { [weak self] in self?.presentation.layoutRevision(for: $0.presentationIDs) ?? 0 }, + interactionRevision: { + MobileAssistantConversation.transcriptInteractionRevision(for: $0, isStreaming: streaming) + } + ) + list.layoutIfNeeded() + } + } + + private final class Harness { + let coordinator = MobileNativeList.Coordinator() + let list = ListView() + var revisions: [UUID: UInt64] = [:] + var interactionRevisions: [UUID: UInt64] = [:] + var renderedIDs: [UUID] = [] + var measuredIDs: [UUID] = [] + var renderedInteractionRevisions: [UUID: UInt64] = [:] + + init() { + list.frame = CGRect(x: 0, y: 0, width: 390, height: 280) + list.estimatedRowHeight = 80 + list.rows { + ListRow(MobileHostingListRow.self).height { [weak self] item, _ in + self?.measuredIDs.append(item.id) + return 80 + }.configure { [weak self] row, item, _ in + self?.coordinator.configure(row, item: item, surface: .plain) + } + } + } + + func update( + _ items: [ChatMessage], followsBottom: Bool = false, + refreshID: AnyHashable = 0, colorScheme: ColorScheme = .light, + tracksInteractions: Bool = true + ) { + let interactions = interactionRevisions coordinator.update( listView: list, items: items, renderer: { [weak self] item in self?.renderedIDs.append(item.id) + self?.renderedInteractionRevisions[item.id] = interactions[item.id] ?? 0 return AnyView(Text(item.text)) }, onSelect: nil, onRefresh: nil, followsBottom: followsBottom, surface: .plain, - refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, - rowRevision: { [weak self] in self?.revisions[$0.id] ?? 0 } + refreshID: refreshID, locale: Locale(identifier: "en"), + colorScheme: colorScheme, dynamicTypeSize: .large, + rowRevision: { [weak self] in self?.revisions[$0.id] ?? 0 }, + interactionRevision: tracksInteractions ? { interactions[$0.id] ?? 0 } : nil ) list.layoutIfNeeded() } diff --git a/TFM/Features/AI/TranscriptListView.swift b/TFM/Features/AI/TranscriptListView.swift index fecd8105..fad7526d 100644 --- a/TFM/Features/AI/TranscriptListView.swift +++ b/TFM/Features/AI/TranscriptListView.swift @@ -96,6 +96,13 @@ struct TranscriptListConfiguration: Equatable { let colorScheme: ColorScheme var horizontalInset: CGFloat { isCompact ? Metrics.Space.l : Metrics.Space.xl } + /// Run state and destination availability only affect existing controls. + /// Keep all other configuration changes on the original reload path. + func hasSameLayout(as other: Self) -> Bool { + isCompact == other.isCompact && showsTerminalActions == other.showsTerminalActions + && locale == other.locale && reduceMotion == other.reduceMotion && colorScheme == other.colorScheme + } + init( isCompact: Bool, showsTerminalActions: Bool, isConversationStreaming: Bool, chatDestinations: [UUID: String] = [:], locale: Locale, reduceMotion: Bool, @@ -552,6 +559,17 @@ struct TranscriptListView: NSViewRepresentable { || oldConfiguration != configuration else { return } + let requiresReload = oldConfiguration?.hasSameLayout(as: configuration) != true + if !requiresReload, oldConfiguration != configuration { + for case let row as TranscriptNativeRow in listView.visibleRowViews { + row.updateInteractionConfiguration(configuration) + } + } + // Interaction metadata must not discard measurements or request a + // bottom follow. Content arriving in the same update still applies + // synchronously through the usual incremental update plan below. + if !requiresReload, content == entries, self.latestUserMessageID == latestUserMessageID { return } + let validPresentationIDs = entries.reduce(into: Set()) { $0.formUnion($1.presentationIDs) } @@ -569,7 +587,7 @@ struct TranscriptListView: NSViewRepresentable { || (listView.isScrolledToBottom(tolerance: Metrics.Space.l) && !listView.isUserInteractingWithScroll) - if oldConfiguration != configuration { + if requiresReload { sizingPool.removeAll() listView.reloadData() } diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index c298504a..90bfadce 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -167,6 +167,16 @@ class TranscriptNativeRow: ListRowView { private var previousPresentation: TranscriptPresentationState? private var wasEditing = false private var previousWidth: CGFloat = 0 + private var interactionBindings: [NativeTranscriptControlBinding] = [] + + /// Update controls without touching the editor, Markdown, or disclosure tree. + func updateInteractionConfiguration(_ configuration: TranscriptListConfiguration) { + guard self.configuration != configuration else { return } + self.configuration = configuration + for binding in interactionBindings { + if let button = binding.button { binding.interaction.apply(to: button, configuration: configuration) } + } + } static func make(for kind: TranscriptEntry.Kind) -> TranscriptNativeRow { switch kind { @@ -207,6 +217,8 @@ class TranscriptNativeRow: ListRowView { previousEntry = nil previousPresentation = nil actionHolder.actions = .init() + configuration = nil + interactionBindings.removeAll() hoverViews.removeAll() if let tracking { removeTrackingArea(tracking) } tracking = nil @@ -223,7 +235,7 @@ class TranscriptNativeRow: ListRowView { invalidate: @escaping (UUID, Bool) -> Void ) { let oldConfiguration = self.configuration - self.configuration = configuration + updateInteractionConfiguration(configuration) actionHolder.actions = actions let editing = presentation.isEditingMessage(entry.id) defer { @@ -231,6 +243,12 @@ class TranscriptNativeRow: ListRowView { previousPresentation = presentation wasEditing = editing previousWidth = proposedWidth + // Work updates can retain individual tool subtrees. Keep their + // bindings, but discard controls replaced or removed by this update. + interactionBindings.removeAll { binding in + guard let button = binding.button, let activeContent else { return true } + return !button.isDescendant(of: activeContent) + } } topInset = entry.hasLeadingSpacing ? Metrics.Chat.turnSpacing : 0 let contentWidth = max( @@ -255,6 +273,10 @@ class TranscriptNativeRow: ListRowView { hoverViews.append(view) view.alphaValue = isHovering ? 1 : 0 }, + registerInteraction: { [weak self] button, interaction in + interaction.apply(to: button, configuration: configuration) + self?.interactionBindings.append(.init(button: button, interaction: interaction)) + }, presentError: { [weak self] title, message in guard !measurement else { return } self?.presentError(title: title, message: message) @@ -262,10 +284,12 @@ class TranscriptNativeRow: ListRowView { ) if representedID == entry.id, let activeContent { contentWidthConstraint?.constant = contentWidth - // Draft revisions must remeasure, not replace the live NSTextView: + // Draft revisions and edit-eligibility changes must not replace a live NSTextView: // replacement loses selection, undo history, and marked IME text. - if case .user = entry.content, previousEntry == entry, - previousPresentation === presentation, oldConfiguration == configuration, + if case .user(let message, let canEdit) = entry.content, + case .user(let previousMessage, let previouslyCanEdit)? = previousEntry?.content, + message == previousMessage, editing || canEdit == previouslyCanEdit, + previousPresentation === presentation, oldConfiguration?.hasSameLayout(as: configuration) == true, wasEditing == editing, editing || previousWidth == proposedWidth { if let editor = activeContent.nativeMessageEditor { @@ -413,6 +437,36 @@ private final class NativeTranscriptActionHolder { var actions = TranscriptListActions() } +@MainActor +private struct NativeTranscriptControlBinding { + weak var button: NSButton? + let interaction: NativeTranscriptInteraction +} + +@MainActor +private enum NativeTranscriptInteraction { + case planImplementation + case chat(UUID) + case origin(UUID, snapshotTitle: String?) + + func apply(to button: NSButton, configuration: TranscriptListConfiguration) { + switch self { + case .planImplementation: + button.isEnabled = !configuration.isConversationStreaming + case .chat(let id): + button.isEnabled = configuration.chatDestinations[id] != nil + case .origin(let id, let snapshotTitle): + let title = configuration.chatDestinations[id] ?? snapshotTitle + let label = AppLocalization.resolve( + title.map { AppStrings.Assistant.agentFrom($0) } ?? AppStrings.Assistant.agent, + locale: configuration.locale) + button.toolTip = label + button.setAccessibilityLabel(label) + button.isEnabled = configuration.chatDestinations[id] != nil + } + } +} + @MainActor private struct NativeTranscriptContext { let entryID: UUID @@ -425,6 +479,7 @@ private struct NativeTranscriptContext { let measurement: Bool let invalidate: (UUID, Bool) -> Void let registerHoverView: (NSView) -> Void + let registerInteraction: (NSButton, NativeTranscriptInteraction) -> Void let presentError: (String, String) -> Void } @@ -759,22 +814,9 @@ private enum NativeTranscriptFactory { context.registerHoverView(hoverActions) } if let origin = message.origin { - let sourceTitle = - context.configuration.chatDestinations[origin.sourceConversationID] - ?? origin.sourceTitle - let label = - sourceTitle.map { - AppLocalization.resolve( - AppStrings.Assistant.agentFrom($0), - locale: context.configuration.locale) - } - ?? AppLocalization.resolve( - AppStrings.Assistant.agent, - locale: context.configuration.locale) - let agent = NativeButton(symbol: "sparkles", help: label) + let agent = NativeButton(symbol: "sparkles", help: "") agent.identifier = NSUserInterfaceItemIdentifier("Transcript.AgentOrigin") - agent.setAccessibilityLabel(label) - agent.isEnabled = context.configuration.chatDestinations[origin.sourceConversationID] != nil + context.registerInteraction(agent, .origin(origin.sourceConversationID, snapshotTitle: origin.sourceTitle)) agent.handler = { context.actions.navigateChat(origin.sourceConversationID) } actions.addArrangedSubview(agent) } @@ -944,7 +986,9 @@ private enum NativeTranscriptFactory { ) -> NSView { let content = existing ?? WorkContent() let stack = content.stack - let environmentChanged = content.configuration != context.configuration || content.width != context.contentWidth + let environmentChanged = + content.configuration?.hasSameLayout(as: context.configuration) != true + || content.width != context.contentWidth let retryChanged = content.headerGroup?.retryProgress != group.retryProgress let revision = context.presentation.layoutRevision( for: Set( @@ -1214,7 +1258,7 @@ private enum NativeTranscriptFactory { prominent: true ) implement.identifier = NSUserInterfaceItemIdentifier("Transcript.PlanImplement") - implement.isEnabled = !context.configuration.isConversationStreaming + context.registerInteraction(implement, .planImplementation) implement.handler = { context.actions.implementPlan(card) } actions.addArrangedSubview(discard) actions.addArrangedSubview(NativeFlexibleSpacer()) @@ -1522,7 +1566,7 @@ private enum NativeTranscriptFactory { link.alignment = .left link.font = font link.contentTintColor = .linkColor - link.isEnabled = context.configuration.chatDestinations[id] != nil + context.registerInteraction(link, .chat(id)) link.handler = { context.actions.navigateChat(id) } row.addArrangedSubview(link) } else if let raw = field.url, let url = URL(string: raw) { @@ -1631,7 +1675,7 @@ private enum NativeTranscriptFactory { link.alignment = .left link.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) link.contentTintColor = .linkColor - link.isEnabled = context.configuration.chatDestinations[id] != nil + context.registerInteraction(link, .chat(id)) link.handler = { context.actions.navigateChat(id) } body.addArrangedSubview(link) } else if let raw = row.url, let url = URL(string: raw) { diff --git a/TFMTests/TranscriptListViewTests.swift b/TFMTests/TranscriptListViewTests.swift index 3dc9969c..3db11292 100644 --- a/TFMTests/TranscriptListViewTests.swift +++ b/TFMTests/TranscriptListViewTests.swift @@ -335,6 +335,313 @@ struct TranscriptListCacheTests { @MainActor @Suite struct TranscriptListViewBridgeTests { + @Test + func interactionMetadataPreservesEditorMarkdownMeasurementsAndHistoryAnchor() async throws { + let coordinator = TranscriptListView.Coordinator() + let list = coordinator.makeListView() + list.frame = CGRect(x: 0, y: 0, width: 600, height: 500) + let presentation = TranscriptPresentationState() + let userID = UUID() + let answerID = UUID() + let destinationID = UUID() + let entries = + [ + user(id: userID, text: "Original"), + assistant(id: answerID, text: "**Retain this Markdown**"), + ] + (0..<15).map { _ in TranscriptEntry(id: UUID(), content: .empty) } + presentation.beginEditingMessage(userID, text: "Draft text") + update(coordinator, list, entries: entries, presentation: presentation) + list.setContentOffset(.zero, animated: false) + await settleResize(list) + let row = try #require(list.rowView(for: userID)) + let answer = try #require(list.rowView(for: answerID)) + let editor = try #require(descendant(in: row, identifier: "Transcript.UserEditorText") as? NSTextView) + let markdown = try #require(descendant(in: answer, ofType: MarkdownTextView.self)) + editor.setSelectedRange(NSRange(location: 3, length: 2)) + editor.setMarkedText( + "中文", selectedRange: NSRange(location: 1, length: 0), replacementRange: editor.selectedRange()) + let selection = editor.selectedRange() + let marked = editor.markedRange() + await settleResize(list) + let anchorOffset = list.rectForRow(with: userID).minY - list.contentOffset.y + var measurements = 0 + let measurementConfiguration = TranscriptListConfiguration( + isCompact: true, showsTerminalActions: false, isConversationStreaming: false, + locale: Locale(identifier: "en"), reduceMotion: true, colorScheme: .light) + func probeMeasurement() { + _ = coordinator.sizingPool.height( + for: entries[0], width: 601, configuration: measurementConfiguration, presentation: presentation + ) { _ in measurements += 1 } + } + probeMeasurement() + for (streaming, title) in [(true, "First title"), (true, "Renamed"), (false, "Renamed")] { + update( + coordinator, list, entries: entries, presentation: presentation, + isConversationStreaming: streaming, chatDestinations: [destinationID: title]) + list.layoutSubtreeIfNeeded() + #expect(list.rowView(for: userID) === row) + #expect(list.rowView(for: answerID) === answer) + #expect(descendant(in: row, identifier: "Transcript.UserEditorText") === editor) + #expect(descendant(in: answer, ofType: MarkdownTextView.self) === markdown) + #expect(editor.selectedRange() == selection) + #expect(editor.markedRange() == marked) + #expect(editor.hasMarkedText()) + #expect(abs(list.rectForRow(with: userID).minY - list.contentOffset.y - anchorOffset) <= 1) + probeMeasurement() + #expect(measurements == 1) + } + coordinator.refreshDisplayedRow(userID, in: list) + #expect(descendant(in: row, identifier: "Transcript.UserEditorText") === editor) + } + + @Test + func modelRunBoundariesPreserveAnActiveMessageEditorWhenEditEligibilityChanges() async throws { + let conversation = ConversationModel() + let messageID = conversation.appendUserTurnContent("Original message").id + let coordinator = TranscriptListView.Coordinator() + let list = coordinator.makeListView() + list.frame = CGRect(x: 0, y: 0, width: 600, height: 500) + let presentation = TranscriptPresentationState() + presentation.beginEditingMessage(messageID, text: "Draft text") + func applyConversation() { + let availability = conversation.messageActionAvailability + let entries = TranscriptEntry.items( + timeline: conversation.timeline, + isInterrupted: conversation.runState == .interrupted, + canEdit: availability.editable.contains, + canFork: availability.forkable.contains + ) + update( + coordinator, list, entries: entries, presentation: presentation, + isConversationStreaming: conversation.isStreaming) + } + #expect(conversation.messageActionAvailability.editable.contains(messageID)) + applyConversation() + await settleResize(list) + let row = try #require(list.rowView(for: messageID)) + let editor = try #require(descendant(in: row, identifier: "Transcript.UserEditorText") as? NSTextView) + editor.setSelectedRange(NSRange(location: 3, length: 2)) + editor.setMarkedText( + "中文", selectedRange: NSRange(location: 1, length: 0), replacementRange: editor.selectedRange()) + let selection = editor.selectedRange() + let marked = editor.markedRange() + for state: AgentRunState in [.running, .completed] { + conversation.handle(.runState(state)) + #expect(conversation.messageActionAvailability.editable.contains(messageID) == !conversation.isStreaming) + applyConversation() + #expect(presentation.isEditingMessage(messageID)) + #expect(list.rowView(for: messageID) === row) + #expect(descendant(in: row, identifier: "Transcript.UserEditorText") === editor) + #expect(editor.selectedRange() == selection) + #expect(editor.markedRange() == marked) + #expect(editor.hasMarkedText()) + } + } + + @Test + func interactionMetadataDoesNotSnapANearBottomReaderToBottom() async { + let coordinator = TranscriptListView.Coordinator() + let list = coordinator.makeListView() + let presentation = TranscriptPresentationState() + list.frame = CGRect(x: 0, y: 0, width: 420, height: 220) + let entries = (0..<15).map { _ in TranscriptEntry(id: UUID(), content: .empty) } + update(coordinator, list, entries: entries, presentation: presentation) + await settleResize(list) + list.setContentOffset(CGPoint(x: 0, y: list.maximumContentOffset.y - 8), animated: false) + let offset = list.contentOffset + #expect(list.isScrolledToBottom(tolerance: Metrics.Space.l)) + update(coordinator, list, entries: entries, presentation: presentation, isConversationStreaming: true) + #expect(list.contentOffset == offset) + } + + @Test + func interactionMetadataUpdatesRetainedPlanAndOriginControlsInBothDirections() async throws { + let coordinator = TranscriptListView.Coordinator() + let list = coordinator.makeListView() + list.frame = CGRect(x: 0, y: 0, width: 600, height: 1200) + let presentation = TranscriptPresentationState() + let sourceID = UUID() + let message = ChatMessage( + role: .user, text: "Delegated request", + origin: AgentMessageOrigin( + sourceConversationID: sourceID, sourceRunID: UUID(), sourceToolCallID: "send", + sourceTitle: "Snapshot title")) + let plan = AgentPlan(title: "Plan", body: "Implement this", scratchPath: "/tmp/plan.md") + let entries = [ + TranscriptEntry(id: message.id, content: .user(message, canEdit: false)), + TranscriptEntry(id: plan.id, content: .plan(AgentPlanCard(plan: plan))), + ] + var navigations: [UUID] = [] + var implementations: [UUID] = [] + let actions = TranscriptListActions( + navigateChat: { navigations.append($0) }, implementPlan: { implementations.append($0.id) }) + update( + coordinator, list, entries: entries, presentation: presentation, actions: actions, + chatDestinations: [sourceID: "Live title"]) + await settleResize(list) + let originRow = try #require(list.rowView(for: message.id)) + let planRow = try #require(list.rowView(for: plan.id)) + let origin = try #require(descendant(in: originRow, identifier: "Transcript.AgentOrigin") as? NSButton) + let implement = try #require(descendant(in: planRow, identifier: "Transcript.PlanImplement") as? NSButton) + for (streaming, destinations, expectedTitle) in [ + (true, [sourceID: "Renamed"], "Renamed"), + (false, [:], "Snapshot title"), + (false, [sourceID: "Restored"], "Restored"), + ] { + update( + coordinator, list, entries: entries, presentation: presentation, actions: actions, + isConversationStreaming: streaming, chatDestinations: destinations) + #expect(list.rowView(for: message.id) === originRow) + #expect(list.rowView(for: plan.id) === planRow) + #expect(descendant(in: originRow, identifier: "Transcript.AgentOrigin") === origin) + #expect(descendant(in: planRow, identifier: "Transcript.PlanImplement") === implement) + #expect(implement.isEnabled == !streaming) + #expect(origin.isEnabled == (destinations[sourceID] != nil)) + let help = AppLocalization.resolve( + AppStrings.Assistant.agentFrom(expectedTitle), locale: Locale(identifier: "en")) + #expect(origin.toolTip == help) + #expect(origin.accessibilityLabel() == help) + } + origin.performClick(nil) + implement.performClick(nil) + #expect(navigations == [sourceID]) + #expect(implementations == [plan.id]) + } + + @Test + func offscreenPlanUsesLatestInteractionMetadataWhenMountedAgain() async throws { + let coordinator = TranscriptListView.Coordinator() + let list = coordinator.makeListView() + list.frame = CGRect(x: 0, y: 0, width: 420, height: 220) + let presentation = TranscriptPresentationState() + let plan = AgentPlan(title: "Plan", body: "Implement this", scratchPath: "/tmp/plan.md") + let entries = + [TranscriptEntry(id: plan.id, content: .plan(AgentPlanCard(plan: plan)))] + + (0..<40).map { _ in TranscriptEntry(id: UUID(), content: .empty) } + update(coordinator, list, entries: entries, presentation: presentation) + await settleResize(list) + #expect(list.rowView(for: plan.id) == nil) + update(coordinator, list, entries: entries, presentation: presentation, isConversationStreaming: true) + list.setContentOffset(.zero, animated: false) + list.layoutSubtreeIfNeeded() + let row = try #require(list.rowView(for: plan.id)) + let implement = try #require(descendant(in: row, identifier: "Transcript.PlanImplement") as? NSButton) + #expect(!implement.isEnabled) + update(coordinator, list, entries: entries, presentation: presentation) + #expect(descendant(in: row, identifier: "Transcript.PlanImplement") === implement) + #expect(implement.isEnabled) + } + + @Test + func simultaneousContentAndInteractionChangesApplyImmediatelyWithoutReplacingUnchangedRows() async throws { + let coordinator = TranscriptListView.Coordinator() + let list = coordinator.makeListView() + list.frame = CGRect(x: 0, y: 0, width: 600, height: 1200) + let presentation = TranscriptPresentationState() + let stable = assistant(id: UUID(), text: "Stable **Markdown**") + let streamingID = UUID() + let plan = AgentPlan(title: "Plan", body: "Implement this", scratchPath: "/tmp/plan.md") + let planEntry = TranscriptEntry(id: plan.id, content: .plan(AgentPlanCard(plan: plan))) + update( + coordinator, list, entries: [stable, streamingAssistant(id: streamingID, text: "First"), planEntry], + presentation: presentation) + await settleResize(list) + let row = try #require(list.rowView(for: stable.id)) + let markdown = try #require(descendant(in: row, ofType: MarkdownTextView.self)) + let planRow = try #require(list.rowView(for: plan.id)) + let implement = try #require(descendant(in: planRow, identifier: "Transcript.PlanImplement") as? NSButton) + let revised = streamingAssistant(id: streamingID, text: "First delta") + update( + coordinator, list, entries: [stable, revised, planEntry], presentation: presentation, + isConversationStreaming: true) + #expect(list.content[1] == revised) + #expect(list.rowView(for: stable.id) === row) + #expect(descendant(in: row, ofType: MarkdownTextView.self) === markdown) + #expect(descendant(in: planRow, identifier: "Transcript.PlanImplement") === implement) + #expect(!implement.isEnabled) + } + + @Test + func interactionMetadataRefreshesNestedToolNavigationWithoutRebuildingWork() async throws { + let coordinator = TranscriptListView.Coordinator() + let list = coordinator.makeListView() + list.frame = CGRect(x: 0, y: 0, width: 600, height: 1200) + let presentation = TranscriptPresentationState() + let runID = UUID() + let destinationID = UUID() + var card = AgentToolCard( + invocation: AgentToolInvocation( + runID: runID, call: AgentToolCall(id: "chats", name: "list_chats", arguments: .object([:]))), + descriptor: AgentToolDescriptor( + name: "list_chats", summary: "List chats", inputSchema: .object([:]), target: .local, + approvalPolicy: .approve, + presentation: .init( + symbol: "bubble.left.and.bubble.right", activity: .semanticLabel(.chats), output: .json, + presenterID: "builtin.list_chats", actionKind: .list))) + card.state = .completed + card.result = AgentToolResult( + callID: "chats", + content: AgentJSONValue.object([ + "chats": .array([ + .object([ + "chat_id": .string(destinationID.uuidString), "title": .string("Linked chat"), + "status": .string("completed"), + ]) + ]) + ]).encodedString) + presentation.disclosures.toggleDisclosure(.tool(card.id)) + var message = ChatMessage(role: .assistant, text: "**Working**") + var group = AgentWorkGroup( + run: AgentRunSummary(id: runID, state: .running, startedAt: .now), + items: [.message(message), .tool(card)], + orderedAt: .now, appearsAfterAnswer: false) + let entry = TranscriptEntry(id: runID, content: .work(group)) + var navigations: [UUID] = [] + let actions = TranscriptListActions(navigateChat: { navigations.append($0) }) + update( + coordinator, list, entries: [entry], presentation: presentation, actions: actions, + chatDestinations: [destinationID: "Linked chat"]) + await settleResize(list) + let row = try #require(list.rowView(for: runID)) + let markdown = try #require(descendant(in: row, ofType: MarkdownTextView.self)) + let link = try #require(descendants(in: row, ofType: NSButton.self).first { $0.title == "Linked chat" }) + let destinationStates: [[UUID: String]] = [[:], [destinationID: "Restored"]] + for destinations in destinationStates { + update( + coordinator, list, entries: [entry], presentation: presentation, actions: actions, + chatDestinations: destinations) + #expect(list.rowView(for: runID) === row) + #expect(descendant(in: row, ofType: MarkdownTextView.self) === markdown) + #expect(descendants(in: row, ofType: NSButton.self).first { $0.title == "Linked chat" } === link) + #expect(link.isEnabled == (destinations[destinationID] != nil)) + } + message.text += " with another delta" + group.items = [.message(message), .tool(card)] + let revised = TranscriptEntry(id: runID, content: .work(group)) + update( + coordinator, list, entries: [revised], presentation: presentation, actions: actions, + chatDestinations: [destinationID: "Restored"]) + #expect(list.content == [revised]) + #expect(descendant(in: row, ofType: MarkdownTextView.self) === markdown) + #expect(descendants(in: row, ofType: NSButton.self).first { $0.title == "Linked chat" } === link) + update(coordinator, list, entries: [revised], presentation: presentation, actions: actions) + #expect(!link.isEnabled, "Reused nested tool controls must retain their interaction binding") + update( + coordinator, list, entries: [revised], presentation: presentation, actions: actions, + chatDestinations: [destinationID: "Restored"]) + link.performClick(nil) + #expect(navigations == [destinationID]) + + group.items = [.message(message)] + let removed = TranscriptEntry(id: runID, content: .work(group)) + update( + coordinator, list, entries: [removed], presentation: presentation, actions: actions, + chatDestinations: [destinationID: "Restored"]) + #expect(!link.isDescendant(of: row)) + update(coordinator, list, entries: [removed], presentation: presentation, actions: actions) + #expect(link.isEnabled, "Detached controls must no longer receive interaction updates") + } + @Test func editingDraftRefreshPreservesTextViewSelectionAndMarkedText() throws { let coordinator = TranscriptListView.Coordinator() @@ -1663,7 +1970,9 @@ struct TranscriptListViewBridgeTests { showsTerminalActions: Bool = false, actions: TranscriptListActions = .init(), reduceMotion: Bool = true, - colorScheme: ColorScheme = .light + colorScheme: ColorScheme = .light, + isConversationStreaming: Bool = false, + chatDestinations: [UUID: String] = [:] ) { coordinator.update( listView: listView, @@ -1672,7 +1981,8 @@ struct TranscriptListViewBridgeTests { configuration: .init( isCompact: isCompact, showsTerminalActions: showsTerminalActions, - isConversationStreaming: false, + isConversationStreaming: isConversationStreaming, + chatDestinations: chatDestinations, locale: Locale(identifier: "en"), reduceMotion: reduceMotion, colorScheme: colorScheme diff --git a/TFMTests/TranscriptPerformanceTests.swift b/TFMTests/TranscriptPerformanceTests.swift index 96dd3bb2..7ae7e7ba 100644 --- a/TFMTests/TranscriptPerformanceTests.swift +++ b/TFMTests/TranscriptPerformanceTests.swift @@ -43,7 +43,7 @@ struct TranscriptPerformanceTests { let lastID = items[items.count - 1].id let stream = elapsed { - // Twenty actual presentation frames, each applied immediately. + // Twenty synchronous presentation updates, not twenty displayed frames. for revision in 0..<20 { let message = ChatMessage( id: lastID, @@ -81,6 +81,122 @@ struct TranscriptPerformanceTests { #expect(stream < 5) } + @Test(arguments: [false, true]) + func modelDrivenStreamingReplayReportsUpdateCosts(readingHistory: Bool) async throws { + let coordinator = TranscriptListView.Coordinator() + let listView = coordinator.makeListView() + listView.frame = CGRect(x: 0, y: 0, width: 420, height: 640) + let presentation = TranscriptPresentationState() + let messages = (0..<300).map { ChatMessage(role: .assistant, text: Self.markdown(index: $0)) } + let conversation = ConversationModel(messages: messages) + let lastID = try #require(messages.last?.id) + + func applyConversation() -> (projection: TimeInterval, rendering: TimeInterval) { + var entries: [TranscriptEntry] = [] + let projection = elapsed { + let availability = conversation.messageActionAvailability + entries = TranscriptEntry.items( + timeline: conversation.timeline, + isInterrupted: conversation.runState == .interrupted, + canEdit: availability.editable.contains, + canFork: availability.forkable.contains + ) + } + let rendering = elapsed { + coordinator.update( + listView: listView, entries: entries, latestUserMessageID: nil, + configuration: .init( + isCompact: true, showsTerminalActions: false, + isConversationStreaming: conversation.isStreaming, + locale: Locale(identifier: "en"), reduceMotion: false, colorScheme: .light + ), + actions: .init(), presentation: presentation + ) + listView.layoutSubtreeIfNeeded() + } + return (projection, rendering) + } + + _ = applyConversation() + if readingHistory { + listView.setContentOffset(CGPoint(x: 0, y: 180), animated: false) + listView.layoutSubtreeIfNeeded() + } + let anchorIndex = try #require(listView.indicesForVisibleRows.first) + let anchorID = listView.content[anchorIndex].id + let anchor = try #require(listView.rowView(for: anchorID)) + let anchorOffset = listView.rectForRow(with: anchorID).minY - listView.contentOffset.y + var projectionSamples: [TimeInterval] = [] + var renderSamples: [TimeInterval] = [] + var eventSamples: [TimeInterval] = [] + var maximumAnchorDrift: CGFloat = 0 + var anchorReplacements = 0 + var previousAnchor = anchor + + conversation.handle(.runState(.running)) + for revision in 0..<20 { + eventSamples.append( + elapsed { + conversation.handle(.messageDelta(messageID: lastID, text: "\n\nReplay paragraph \(revision).")) + }) + let sample = applyConversation() + projectionSamples.append(sample.projection) + renderSamples.append(sample.rendering) + // Give deferred layout work a turn; this remains an offscreen replay, + // not a measurement of display refresh or animation hitch rate. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + listView.layoutSubtreeIfNeeded() + if readingHistory { + let current = try #require(listView.rowView(for: anchorID)) + if current !== previousAnchor { anchorReplacements += 1 } + previousAnchor = current + maximumAnchorDrift = max( + maximumAnchorDrift, + abs(listView.rectForRow(with: anchorID).minY - listView.contentOffset.y - anchorOffset) + ) + } + } + conversation.handle(.runState(.completed)) + _ = applyConversation() + if readingHistory { + let current = try #require(listView.rowView(for: anchorID)) + if current !== previousAnchor { anchorReplacements += 1 } + maximumAnchorDrift = max( + maximumAnchorDrift, + abs(listView.rectForRow(with: anchorID).minY - listView.contentOffset.y - anchorOffset) + ) + #expect(current === anchor) + #expect(maximumAnchorDrift <= 1) + } + let last = try #require(listView.content.first(where: { $0.id == lastID })) + guard case .assistant(let message, _) = last.content else { + Issue.record("Expected the streamed assistant message") + return + } + #expect(message.text.hasSuffix("Replay paragraph 19.")) + #expect(!message.isStreaming) + print( + String( + format: + "TRANSCRIPT_MODEL_REPLAY history=%@ event_p95_ms=%.2f projection_p95_ms=%.2f render_p50_ms=%.2f render_p95_ms=%.2f render_max_ms=%.2f anchor_drift=%.2f anchor_replacements=%d", + readingHistory.description, + percentile(eventSamples, fraction: 0.95) * 1_000, + percentile(projectionSamples, fraction: 0.95) * 1_000, + percentile(renderSamples, fraction: 0.5) * 1_000, + percentile(renderSamples, fraction: 0.95) * 1_000, + (renderSamples.max() ?? 0) * 1_000, + Double(maximumAnchorDrift), anchorReplacements + )) + } + + private func percentile(_ samples: [TimeInterval], fraction: Double) -> TimeInterval { + let sorted = samples.sorted() + guard !sorted.isEmpty else { return 0 } + return sorted[min(sorted.count - 1, max(0, Int(ceil(Double(sorted.count) * fraction)) - 1))] + } + private func makeItems(count: Int) -> [TranscriptEntry] { (0..