// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import AgentKit import ListViewKit import SwiftUI import TermindApplication import TermindPresentation import Testing import UIKit @testable import TermindIOS @Suite(.serialized) @MainActor struct MobileAssistantTranscriptTests { @Test func userBubbleShrinksForShortTextAndWrapsAtTheAvailableWidth() { func measuredWidth(_ text: String) -> CGFloat { let controller = UIHostingController(rootView: MobileAssistantUserBubble(text: text)) return controller.sizeThatFits(in: CGSize(width: 320, height: 1_000)).width } let short = measuredWidth("Hi") let long = measuredWidth(String(repeating: "A long message that should wrap. ", count: 20)) #expect(short < 160) #expect(long > short) #expect(long <= 320) } @Test func imageFlowKeepsEveryUserRowTrailingAndAssistantRowsLeading() { let tile = CGSize(width: 104, height: 104) let sizes = Array(repeating: tile, count: 5) let user = MobileAssistantImageFlowLayout.placements( for: sizes, in: 320, spacing: 8, alignment: .trailing ) #expect(user.map(\.index) == [0, 1, 2, 3, 4]) #expect(user.map(\.size) == sizes) #expect(user[0].origin == CGPoint(x: 104, y: 0)) #expect(user[1].origin == CGPoint(x: 216, y: 0)) #expect(user[2].origin == CGPoint(x: 104, y: 112)) #expect(user[3].origin == CGPoint(x: 216, y: 112)) #expect(user[4].origin == CGPoint(x: 216, y: 224)) #expect(user[1].origin.x + user[1].size.width == 320) #expect(user[3].origin.x + user[3].size.width == 320) #expect(user[4].origin.x + user[4].size.width == 320) let assistant = MobileAssistantImageFlowLayout.placements( for: sizes, in: 320, spacing: 8, alignment: .leading ) #expect(assistant[0].origin.x == 0) #expect(assistant[2].origin.x == 0) #expect(assistant[4].origin.x == 0) } @Test func scanningStopsForReducedMotionMeasurementAndInactiveScenes() async throws { // This owns a foreground window, so run it with the opt-in visual checks // rather than competing with unrelated native toolbar tests. guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) for (displayed, reduced, active) in [ (true, false, true), (true, true, true), (false, false, true), (true, false, false), ] { let window = UIWindow(windowScene: scene) let controller = UIHostingController( rootView: Text("Working…").font(.title).foregroundStyle(.black) .modifier(MobileAssistantScanning(active: true)) .frame(maxWidth: .infinity, maxHeight: .infinity).background(.white) .environment(\.mobileListRowIsDisplayed, displayed) .environment(\.mobileListReduceMotion, reduced) .environment(\.mobileListSceneIsActive, active) ) window.rootViewController = controller window.windowLevel = .alert + 3 window.makeKeyAndVisible() defer { window.isHidden = true window.rootViewController = nil } controller.view.layoutIfNeeded() try await Task.sleep(for: .milliseconds(300)) func snapshot() throws -> Data { let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) } return try #require(image.pngData()) } let first = try snapshot() try await Task.sleep(for: .milliseconds(250)) let second = try snapshot() let directory = FileManager.default.temporaryDirectory.appending(path: "assistant-screenshots") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try first.write(to: directory.appending(path: "motion-\(displayed)-\(reduced)-\(active)-first.png")) try second.write(to: directory.appending(path: "motion-\(displayed)-\(reduced)-\(active)-second.png")) if displayed && !reduced && active { #expect(first != second, "The live title should scan.") } else { #expect(first == second, "A static title must not animate.") } } } @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") 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() var streaming = first streaming.text += " delta" harness.update([streaming, second]) #expect(harness.list.rowView(for: first.id) === row) #expect(row.subviews.first?.subviews.first === hosted) #expect(!harness.renderedIDs.contains(second.id)) harness.renderedIDs.removeAll() harness.revisions[first.id] = 1 harness.update([streaming, second]) #expect(harness.renderedIDs.contains(first.id)) #expect(!harness.renderedIDs.contains(second.id)) #expect(row.subviews.first?.subviews.first === hosted) } @Test func appendingDoesNotReplaceExistingRowsOrFollowWhileReadingHistory() async throws { let harness = Harness() let items = (0..<30).map { message("Message \($0)") } harness.update(items) harness.list.setContentOffset(CGPoint(x: 0, y: 120), animated: false) harness.list.layoutIfNeeded() let offset = harness.list.contentOffset.y let visible = try #require(harness.list.indicesForVisibleRows.first) let row = try #require(harness.list.rowView(for: items[visible].id)) harness.update(items + [message("New tail")], followsBottom: true) try await Task.sleep(for: .milliseconds(30)) #expect(harness.list.rowView(for: items[visible].id) === row) #expect(abs(harness.list.contentOffset.y - offset) < 1) } @Test func visibleWorkCollapsesOnlyAfterItFullyLeavesTheMobileViewport() async throws { typealias Adapter = MobileNativeList let presentation = AssistantTranscriptPresentationState() let coordinator = Adapter.Coordinator() let list = ListView() list.frame = CGRect(x: 0, y: 0, width: 390, height: 180) list.rows { ListRow(MobileHostingListRow.self).height { _, _ in 100 }.configure { row, item, _ in coordinator.configure(row, item: item, surface: .plain) } } coordinator.observeViewport(of: list) defer { coordinator.stopObservingViewport() } let id = UUID() let tail = (0..<12).map { _ in AssistantTranscriptEntry(id: UUID(), content: .empty) } func update(_ state: AgentRunState) { let run = AgentRunSummary(id: id, state: state, startedAt: .now) let entry = AssistantTranscriptEntry( id: id, content: .work( .init( run: run, items: [], orderedAt: .now, appearsAfterAnswer: false ))) coordinator.update( listView: list, items: [entry] + tail, renderer: { _ in AnyView(Text("Work")) }, onSelect: nil, onRefresh: nil, followsBottom: false, surface: .plain, refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, prepareUpdate: { presentation.prepareWorkCompletionTransitions(from: $0, to: $1, visibleIDs: $2) }, onViewportChange: { presentation.collapseWorkOutsideViewport(visibleIDs: $0) } ) list.layoutIfNeeded() } update(.running) update(.completed) #expect(presentation.hasPendingAutoCollapse(id)) let frame = list.rectForRow(with: id) list.setContentOffset(CGPoint(x: 0, y: frame.maxY - 1), animated: false) list.layoutIfNeeded() try await Task.sleep(for: .milliseconds(30)) #expect(presentation.isExpanded(.work(id))) list.setContentOffset(CGPoint(x: 0, y: frame.maxY + 1), animated: false) list.layoutIfNeeded() try await Task.sleep(for: .milliseconds(30)) #expect(!presentation.isExpanded(.work(id))) #expect(!presentation.hasPendingAutoCollapse(id)) } @Test func nativeListTapCallbackRecognizesAlongsideHostedRowControls() throws { typealias Adapter = MobileNativeList let coordinator = Adapter.Coordinator() let list = ListView() var tapCount = 0 coordinator.installTapRecognizer(on: list) coordinator.update( listView: list, items: [], renderer: { _ in AnyView(Text("Message")) }, onSelect: nil, onRefresh: nil, followsBottom: false, surface: .plain, refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, onTap: { tapCount += 1 } ) let tap = try #require(list.gestureRecognizers?.compactMap { $0 as? UITapGestureRecognizer }.first) #expect(tap.cancelsTouchesInView == false) #expect(tap.delegate === coordinator) #expect( coordinator.gestureRecognizer( tap, shouldRecognizeSimultaneouslyWith: UITapGestureRecognizer() )) coordinator.listTapped() #expect(tapCount == 1) } @Test func hostedRowsLeaveSelectionAndContextMenuTapCoordinationToSwiftUI() throws { let row = MobileHostingListRow() row.configure(rootView: AnyView(Text("File")), selectable: true, backgroundColor: .systemBackground) let contentContainer = try #require(row.subviews.first) let gestures = contentContainer.gestureRecognizers ?? [] #expect(!gestures.contains { $0 is UITapGestureRecognizer }) #expect(gestures.contains { $0 is UILongPressGestureRecognizer }) } @Test func independentToolHostingRootRetainsNavigationWhenReconfiguredOffscreen() async throws { let row = MobileHostingListRow() row.frame = CGRect(x: 0, y: 0, width: 390, height: 120) var navigation: (@MainActor (AgentToolDetail.NavigationReference) -> Void)? var destinations: [UUID] = [] var handlers: [Int] = [] let destination = UUID() func configure(handler: Int) { row.configure( rootView: AnyView( MobileAssistantTranscriptRowRoot(navigate: { reference in guard case .chat(let id) = reference else { return } destinations.append(id) handlers.append(handler) }) { AssistantToolDetails(items: [ .field(.init(label: "Chat", value: "Open conversation", navigation: .chat(destination))) ]) .background { ToolNavigationProbe { navigation = $0 } } }), selectable: false, backgroundColor: .systemBackground ) row.setNeedsLayout() row.layoutIfNeeded() _ = row.systemLayoutSizeFitting( CGSize(width: 390, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel ) } configure(handler: 1) try await Task.sleep(for: .milliseconds(20)) let hosted = try #require(row.subviews.first?.subviews.first) let firstNavigation = try #require(navigation) firstNavigation(.chat(destination)) #expect(destinations == [destination]) #expect(handlers == [1]) navigation = nil configure(handler: 2) try await Task.sleep(for: .milliseconds(20)) #expect(row.subviews.first?.subviews.first === hosted) let currentNavigation = try #require(navigation) let nextDestination = UUID() currentNavigation(.chat(nextDestination)) #expect(destinations == [destination, nextDestination]) #expect(handlers == [1, 2]) #expect(row.window == nil) } private struct ToolNavigationProbe: UIViewRepresentable { @Environment(\.assistantToolNavigation) private var navigate typealias Navigation = @MainActor (AgentToolDetail.NavigationReference) -> Void let receive: (Navigation?) -> Void func makeUIView(context: Context) -> UIView { receive(navigate) return UIView() } func updateUIView(_ uiView: UIView, context: Context) { receive(navigate) } } @Test(.requiresKeyWindow) func hostedToolNavigationUsesTheCurrentReferenceAndHandlerAfterRowReuse() async throws { let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) let window = UIWindow(windowScene: scene) let controller = UIViewController() window.rootViewController = controller window.makeKeyAndVisible() defer { window.isHidden = true window.rootViewController = nil } let row = MobileHostingListRow() row.frame = CGRect(x: 0, y: 0, width: 390, height: 120) controller.view.addSubview(row) var selected: [UUID] = [] var handlers: [Int] = [] let firstID = UUID() let secondID = UUID() func configure(reference: UUID, handler: Int) { row.configure( rootView: AnyView( MobileAssistantTranscriptRowRoot(navigate: { reference in guard case .chat(let id) = reference else { return } selected.append(id) handlers.append(handler) }) { AssistantToolDetails(items: [ .field(.init(label: "Chat", value: "Open conversation", navigation: .chat(reference))) ]) }), selectable: false, backgroundColor: .systemBackground ) controller.view.layoutIfNeeded() row.layoutIfNeeded() } configure(reference: firstID, handler: 1) try await Task.sleep(for: .milliseconds(50)) let hosted = try #require(row.subviews.first?.subviews.first) let firstButton = try #require(navigationButton(in: row)) #expect(firstButton.accessibilityActivate()) #expect(selected == [firstID]) #expect(handlers == [1]) configure(reference: secondID, handler: 2) try await Task.sleep(for: .milliseconds(50)) #expect(row.subviews.first?.subviews.first === hosted) let reusedButton = try #require(navigationButton(in: row)) #expect(reusedButton.accessibilityActivate()) #expect(selected == [firstID, secondID]) #expect(handlers == [1, 2]) } private func navigationButton(in root: NSObject) -> NSObject? { accessibilityButton(in: root, label: "Open conversation") } private func accessibilityButton(in root: NSObject, label: String) -> NSObject? { var seen = Set() func visit(_ element: NSObject) -> NSObject? { guard seen.insert(ObjectIdentifier(element)).inserted else { return nil } if element.accessibilityTraits.contains(.button), element.accessibilityLabel == label { return element } var children: [NSObject] = (element as? UIView)?.subviews ?? [] let count = element.accessibilityElementCount() if count != NSNotFound, count > 0 { children += (0.. ChatMessage { .init(role: .assistant, text: text, isStreaming: true) } private final class ConversationHarness { let coordinator = MobileNativeList.Coordinator() let list = ListView() private(set) var entries: [AssistantTranscriptEntry] = [] let presentation = AssistantTranscriptPresentationState() var renderedIDs: [UUID] = [] var implementedIDs: [UUID] = [] init(rowHeight: CGFloat = 80) { list.frame = CGRect(x: 0, y: 0, width: 390, height: 280) list.estimatedRowHeight = rowHeight list.rows { ListRow(MobileHostingListRow.self).height { _, _ in rowHeight }.configure { [weak self] row, item, _ in self?.coordinator.configure(row, item: item, surface: .plain) } } } func update(_ conversation: ConversationModel, followsBottom: Bool = false) { let streaming = conversation.isStreaming let archived = conversation.isArchived let availability = conversation.messageActionAvailability entries = AssistantTranscriptEntry.items( timeline: conversation.timeline, isInterrupted: conversation.runState == .interrupted, canEdit: availability.editable.contains, canFork: availability.forkable.contains ) coordinator.update( listView: list, items: entries, renderer: { [weak self] item in self?.renderedIDs.append(item.id) switch item.content { case .assistant(let message, _), .user(let message, _): return AnyView(Text(message.text)) case .plan(let card): guard let self else { return AnyView(EmptyView()) } return AnyView( MobileAssistantPlanRow( card: card, presentation: presentation, readOnly: archived, isStreaming: streaming, implement: { [weak self] in self?.implementedIDs.append(card.id) }, discard: {} ) ) default: return AnyView(Text("Transcript row")) } }, onSelect: nil, onRefresh: nil, followsBottom: followsBottom, surface: .plain, refreshID: MobileAssistantConversation.transcriptRefreshID(for: conversation), locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, rowRevision: { [weak self] in self?.presentation.layoutRevision(for: $0.presentationIDs) ?? 0 }, interactionRevision: { MobileAssistantConversation.transcriptInteractionRevision(for: $0, isStreaming: streaming) } ) list.layoutIfNeeded() } } private final class Harness { let coordinator = MobileNativeList.Coordinator() let list = ListView() var revisions: [UUID: UInt64] = [:] var interactionRevisions: [UUID: UInt64] = [:] var renderedIDs: [UUID] = [] var measuredIDs: [UUID] = [] var renderedInteractionRevisions: [UUID: UInt64] = [:] init() { list.frame = CGRect(x: 0, y: 0, width: 390, height: 280) list.estimatedRowHeight = 80 list.rows { ListRow(MobileHostingListRow.self).height { [weak self] item, _ in self?.measuredIDs.append(item.id) return 80 }.configure { [weak self] row, item, _ in self?.coordinator.configure(row, item: item, surface: .plain) } } } func update( _ items: [ChatMessage], followsBottom: Bool = false, refreshID: AnyHashable = 0, colorScheme: ColorScheme = .light, tracksInteractions: Bool = true ) { let interactions = interactionRevisions coordinator.update( listView: list, items: items, renderer: { [weak self] item in self?.renderedIDs.append(item.id) self?.renderedInteractionRevisions[item.id] = interactions[item.id] ?? 0 return AnyView(Text(item.text)) }, onSelect: nil, onRefresh: nil, followsBottom: followsBottom, surface: .plain, refreshID: 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() } } }