fix(assistant): preserve transcript continuity across run transitions

Separate interaction refreshes from layout invalidation on macOS and iOS. Retain active editors across edit-eligibility changes and cover hosting identity, input composition, navigation controls, and model-driven streaming replay.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Kaede AkinoandClaude Code committed 2026-10-18 14:15:04 +08:00
1 parent 8b6325db7b
commit c43e5bc311
8 files changed
+884 -39

No files matched your search

@@ -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 {
+28
View File
@@ -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<Item.ID>) -> Void)?
let onViewportChange: ((Set<Item.ID>) -> 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<Item.ID>) -> Void)? = nil,
onViewportChange: ((Set<Item.ID>) -> 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<Item.ID>?
private var onViewportChange: ((Set<Item.ID>) -> Void)?
private var onTap: (() -> Void)?
private weak var listView: ListView<Item>?
@@ -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<Item.ID>) -> Void)? = nil,
onViewportChange: ((Set<Item.ID>) -> 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,
+10
View File
@@ -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()
}
+318 -11
View File
@@ -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<ObjectIdentifier>()
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..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
return children.lazy.compactMap { visit($0) }.first
for child in children {
if let button = visit(child) { return button }
}
return nil
}
return visit(root)
}
private func message(_ text: String) -> ChatMessage { .init(role: .assistant, text: text, isStreaming: true) }
private final class Harness {
let coordinator = MobileNativeList<ChatMessage, Text>.Coordinator()
let list = ListView<ChatMessage>()
var revisions: [UUID: UInt64] = [:]
private final class ConversationHarness {
let coordinator = MobileNativeList<AssistantTranscriptEntry, Text>.Coordinator()
let list = ListView<AssistantTranscriptEntry>()
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<ChatMessage, Text>.Coordinator()
let list = ListView<ChatMessage>()
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()
}
+19 -1
View File
@@ -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<UUID>()) {
$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()
}
+67 -23
View File
@@ -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) {
+312 -2
View File
@@ -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
+117 -1
View File
@@ -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..<count).map { index in
let id = UUID()