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:
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user