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>
713 lines
33 KiB
Swift
713 lines
33 KiB
Swift
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<AssistantTranscriptEntry, Text>
|
|
let presentation = AssistantTranscriptPresentationState()
|
|
let coordinator = Adapter.Coordinator()
|
|
let list = ListView<AssistantTranscriptEntry>()
|
|
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<ChatMessage, Text>
|
|
let coordinator = Adapter.Coordinator()
|
|
let list = ListView<ChatMessage>()
|
|
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<ObjectIdentifier>()
|
|
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..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
|
|
}
|
|
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 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(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<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: 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()
|
|
}
|
|
}
|
|
}
|