1403 lines
52 KiB
Swift
1403 lines
52 KiB
Swift
import AgentKit
|
||
import AppKit
|
||
import Foundation
|
||
import ListViewKit
|
||
import SwiftUI
|
||
import TermindApplication
|
||
import TermindDomain
|
||
import TermindInfrastructure
|
||
import TermindPersistence
|
||
import TermindPresentation
|
||
import TermindRemoteInfrastructure
|
||
import Testing
|
||
|
||
@testable import Termind
|
||
|
||
@Suite
|
||
struct TranscriptListUpdatePlanTests {
|
||
@Test
|
||
func firstSnapshotUsesApply() {
|
||
let plan = TranscriptListUpdatePlan.make(old: [], new: [item(1)])
|
||
|
||
#expect(plan.replacesAll)
|
||
#expect(plan.updates.isEmpty)
|
||
#expect(plan.appends.isEmpty)
|
||
}
|
||
|
||
@Test
|
||
func pureTailGrowthUsesAppendWithoutRevisitingExistingRows() {
|
||
let first = item(1)
|
||
let second = item(2)
|
||
let plan = TranscriptListUpdatePlan.make(old: [first], new: [first, second])
|
||
|
||
#expect(!plan.replacesAll)
|
||
#expect(plan.updates.isEmpty)
|
||
#expect(plan.appends == [second])
|
||
}
|
||
|
||
@Test
|
||
func aStreamingRevisionUpdatesOnlyItsStableRow() {
|
||
let id = uuid(1)
|
||
let old = TranscriptEntry(id: id, content: .empty)
|
||
let new = TranscriptEntry(id: id, content: .interrupted)
|
||
let untouched = item(2)
|
||
let plan = TranscriptListUpdatePlan.make(
|
||
old: [old, untouched], new: [new, untouched]
|
||
)
|
||
|
||
#expect(!plan.replacesAll)
|
||
#expect(plan.updates == [new])
|
||
#expect(plan.appends.isEmpty)
|
||
}
|
||
|
||
@Test
|
||
func removalOrReorderingFallsBackToDiffedApply() {
|
||
let first = item(1)
|
||
let second = item(2)
|
||
|
||
#expect(
|
||
TranscriptListUpdatePlan.make(
|
||
old: [first, second], new: [second, first]
|
||
).replacesAll)
|
||
#expect(
|
||
TranscriptListUpdatePlan.make(
|
||
old: [first, second], new: [first]
|
||
).replacesAll)
|
||
}
|
||
|
||
@Test
|
||
func appendingMarksOnlyTheNewRowForLeadingSpacing() {
|
||
let firstTimeline = TimelineItem.message(
|
||
ChatMessage(
|
||
id: uuid(1), role: .assistant, text: "first"
|
||
))
|
||
let secondTimeline = TimelineItem.message(
|
||
ChatMessage(
|
||
id: uuid(2), role: .assistant, text: "second"
|
||
))
|
||
|
||
let old = TranscriptEntry.items(
|
||
timeline: [firstTimeline], isInterrupted: false,
|
||
canEdit: { _ in false }, canFork: { _ in false }
|
||
)
|
||
let new = TranscriptEntry.items(
|
||
timeline: [firstTimeline, secondTimeline], isInterrupted: false,
|
||
canEdit: { _ in false }, canFork: { _ in false }
|
||
)
|
||
let plan = TranscriptListUpdatePlan.make(old: old, new: new)
|
||
|
||
#expect(!old[0].hasLeadingSpacing)
|
||
#expect(!new[0].hasLeadingSpacing)
|
||
#expect(new[1].hasLeadingSpacing)
|
||
#expect(plan.updates.isEmpty)
|
||
#expect(plan.appends == [new[1]])
|
||
}
|
||
|
||
private func item(_ value: UInt8) -> TranscriptEntry {
|
||
TranscriptEntry(id: uuid(value), content: .empty)
|
||
}
|
||
|
||
private func uuid(_ value: UInt8) -> UUID {
|
||
let bytes = uuid_t(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, value)
|
||
return UUID(uuid: bytes)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Suite
|
||
struct TranscriptPresentationStateTests {
|
||
@Test
|
||
func messageDraftSurvivesRowRecyclingAndCanBePruned() {
|
||
let id = UUID()
|
||
let state = TranscriptPresentationState()
|
||
|
||
state.beginEditingMessage(id, text: "draft")
|
||
state.setMessageEditText("updated", for: id)
|
||
state.setMessageBubbleWidth(240, for: id)
|
||
|
||
#expect(state.isEditingMessage(id))
|
||
#expect(state.messageEditText(id) == "updated")
|
||
#expect(state.messageBubbleWidth(id) == 240)
|
||
|
||
state.prune(validTimelineIDs: [])
|
||
#expect(!state.isEditingMessage(id))
|
||
#expect(state.messageEditText(id).isEmpty)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Suite
|
||
struct TranscriptListCacheTests {
|
||
@Test
|
||
func fullScreenAndSidebarUseIndependentNativeLists() {
|
||
let cache = TranscriptListCache()
|
||
let conversationID = UUID()
|
||
let fullScreen = cache.owner(
|
||
for: .init(
|
||
conversationID: conversationID, surface: .fullScreen
|
||
)
|
||
)
|
||
let sidebar = cache.owner(
|
||
for: .init(
|
||
conversationID: conversationID, surface: .sidebar
|
||
)
|
||
)
|
||
|
||
#expect(fullScreen !== sidebar)
|
||
#expect(fullScreen.listView !== sidebar.listView)
|
||
#expect(cache.count == 2)
|
||
}
|
||
|
||
@Test
|
||
func leastRecentlyUsedUnmountedListIsEvictedAtTheLimit() {
|
||
let cache = TranscriptListCache(limit: 8)
|
||
let keys = (0..<9).map { index in
|
||
TranscriptCacheKey(
|
||
conversationID: UUID(),
|
||
surface: index.isMultiple(of: 2) ? .fullScreen : .sidebar
|
||
)
|
||
}
|
||
for key in keys.prefix(8) { _ = cache.owner(for: key) }
|
||
|
||
_ = cache.owner(for: keys[0])
|
||
_ = cache.owner(for: keys[8])
|
||
|
||
#expect(cache.count == 8)
|
||
#expect(cache.cachedOwner(for: keys[0]) != nil)
|
||
#expect(cache.cachedOwner(for: keys[1]) == nil)
|
||
#expect(cache.cachedOwner(for: keys[8]) != nil)
|
||
}
|
||
|
||
@Test
|
||
func mountedListDefersEvictionUntilItUnmounts() {
|
||
let cache = TranscriptListCache(limit: 1)
|
||
let firstKey = TranscriptCacheKey(
|
||
conversationID: UUID(), surface: .fullScreen
|
||
)
|
||
let secondKey = TranscriptCacheKey(
|
||
conversationID: UUID(), surface: .fullScreen
|
||
)
|
||
let first = cache.owner(for: firstKey)
|
||
let container = TranscriptMountView()
|
||
first.mount(in: container)
|
||
|
||
_ = cache.owner(for: secondKey)
|
||
#expect(cache.count == 2)
|
||
#expect(cache.cachedOwner(for: firstKey) === first)
|
||
|
||
first.unmount(from: container)
|
||
#expect(cache.count == 1)
|
||
#expect(cache.cachedOwner(for: firstKey) == nil)
|
||
#expect(cache.cachedOwner(for: secondKey) != nil)
|
||
}
|
||
|
||
@Test
|
||
func remountKeepsNativeIdentityAndReaderOffset() {
|
||
let owner = PersistentTranscriptListOwner(
|
||
key: .init(
|
||
conversationID: UUID(), surface: .fullScreen
|
||
)
|
||
)
|
||
let firstContainer = TranscriptMountView(
|
||
frame: CGRect(x: 0, y: 0, width: 320, height: 180)
|
||
)
|
||
owner.mount(in: firstContainer)
|
||
firstContainer.layoutSubtreeIfNeeded()
|
||
let entries = (0..<12).map { _ in TranscriptEntry(id: UUID(), content: .empty) }
|
||
update(owner, entries: entries)
|
||
owner.listView.setContentOffset(CGPoint(x: 0, y: 72), animated: false)
|
||
let offset = owner.listView.contentOffset
|
||
let nativeList = owner.listView
|
||
|
||
owner.unmount(from: firstContainer)
|
||
let secondContainer = TranscriptMountView(frame: firstContainer.frame)
|
||
owner.mount(in: secondContainer)
|
||
secondContainer.layoutSubtreeIfNeeded()
|
||
|
||
#expect(owner.listView === nativeList)
|
||
#expect(owner.listView.superview === secondContainer)
|
||
#expect(owner.listView.contentOffset == offset)
|
||
}
|
||
|
||
@Test
|
||
func detachedBottomFollowerCatchesUpButHistoryReaderStaysPut() {
|
||
let owner = PersistentTranscriptListOwner(
|
||
key: .init(
|
||
conversationID: UUID(), surface: .fullScreen
|
||
)
|
||
)
|
||
let firstContainer = TranscriptMountView(
|
||
frame: CGRect(x: 0, y: 0, width: 320, height: 180)
|
||
)
|
||
owner.mount(in: firstContainer)
|
||
firstContainer.layoutSubtreeIfNeeded()
|
||
var entries = (0..<12).map { _ in TranscriptEntry(id: UUID(), content: .empty) }
|
||
update(owner, entries: entries)
|
||
owner.listView.scrollToBottom(animated: false)
|
||
owner.unmount(from: firstContainer)
|
||
|
||
entries.append(TranscriptEntry(id: UUID(), content: .empty))
|
||
let secondContainer = TranscriptMountView(frame: firstContainer.frame)
|
||
owner.mount(in: secondContainer)
|
||
secondContainer.layoutSubtreeIfNeeded()
|
||
update(owner, entries: entries)
|
||
#expect(owner.listView.contentOffset.y == owner.listView.maximumContentOffset.y)
|
||
|
||
owner.listView.setContentOffset(CGPoint(x: 0, y: 40), animated: false)
|
||
let readerOffset = owner.listView.contentOffset
|
||
owner.unmount(from: secondContainer)
|
||
entries.append(TranscriptEntry(id: UUID(), content: .empty))
|
||
let thirdContainer = TranscriptMountView(frame: firstContainer.frame)
|
||
owner.mount(in: thirdContainer)
|
||
thirdContainer.layoutSubtreeIfNeeded()
|
||
update(owner, entries: entries)
|
||
|
||
#expect(owner.listView.contentOffset == readerOffset)
|
||
#expect(!owner.listView.isScrolledToBottom(tolerance: Metrics.Space.l))
|
||
}
|
||
|
||
@Test
|
||
func deletingClearsBothSurfacesWhileArchivingKeepsThem() {
|
||
let window = WindowModel()
|
||
let conversation = window.assistantLibrary.createConversation()
|
||
let fullKey = TranscriptCacheKey(
|
||
conversationID: conversation.id, surface: .fullScreen
|
||
)
|
||
let sidebarKey = TranscriptCacheKey(
|
||
conversationID: conversation.id, surface: .sidebar
|
||
)
|
||
_ = window.transcriptCache.owner(for: fullKey)
|
||
_ = window.transcriptCache.owner(for: sidebarKey)
|
||
|
||
window.setAssistantArchived(conversation.id, archived: true)
|
||
#expect(window.transcriptCache.count == 2)
|
||
|
||
window.deleteAssistantConversation(conversation.id)
|
||
#expect(window.transcriptCache.count == 0)
|
||
}
|
||
|
||
private func update(
|
||
_ owner: PersistentTranscriptListOwner,
|
||
entries: [TranscriptEntry]
|
||
) {
|
||
owner.update(
|
||
entries: entries,
|
||
latestUserMessageID: nil,
|
||
configuration: .init(
|
||
isCompact: false,
|
||
showsTerminalActions: false,
|
||
isConversationStreaming: false,
|
||
locale: Locale(identifier: "en"),
|
||
reduceMotion: true,
|
||
colorScheme: .light
|
||
),
|
||
actions: .init()
|
||
)
|
||
owner.listView.layoutSubtreeIfNeeded()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Suite
|
||
struct TranscriptListViewBridgeTests {
|
||
@Test
|
||
func richTranscriptDoesNotInstallAPerFrameRowAnimator() {
|
||
let listView = TranscriptListView.Coordinator().makeListView()
|
||
#expect(listView.rowAnimator == nil)
|
||
}
|
||
|
||
@Test
|
||
func visibleCompletedWorkDefersCollapseUntilItLeavesTheViewport() async {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let presentation = TranscriptPresentationState()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 180)
|
||
let tail = (0..<12).map { _ in TranscriptEntry(id: UUID(), content: .empty) }
|
||
|
||
update(
|
||
coordinator, listView,
|
||
entries: [work(id: id, state: .running)] + tail,
|
||
presentation: presentation
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
listView.setContentOffset(.zero, animated: false)
|
||
listView.layoutSubtreeIfNeeded()
|
||
update(
|
||
coordinator, listView,
|
||
entries: [work(id: id, state: .completed)] + tail,
|
||
presentation: presentation
|
||
)
|
||
|
||
#expect(presentation.disclosures.isExpanded(.work(id)))
|
||
#expect(presentation.disclosures.hasPendingAutoCollapse(id))
|
||
|
||
let expandedFrame = listView.rectForRow(with: id)
|
||
listView.setContentOffset(
|
||
CGPoint(x: 0, y: expandedFrame.maxY - 1),
|
||
animated: false
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
await Task.yield()
|
||
#expect(presentation.disclosures.isExpanded(.work(id)), "one visible point must defer collapse")
|
||
|
||
listView.setContentOffset(
|
||
CGPoint(x: 0, y: listView.maximumContentOffset.y),
|
||
animated: false
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
await Task.yield()
|
||
|
||
#expect(!presentation.disclosures.isExpanded(.work(id)))
|
||
#expect(!presentation.disclosures.hasPendingAutoCollapse(id))
|
||
}
|
||
|
||
@Test
|
||
func streamingUpdateKeepsTheMountedAssistantRowIdentity() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let first = TranscriptEntry(
|
||
id: id,
|
||
content: .assistant(
|
||
ChatMessage(id: id, role: .assistant, text: "First", isStreaming: true),
|
||
canFork: false
|
||
)
|
||
)
|
||
update(coordinator, listView, entries: [first])
|
||
let mounted = try #require(listView.rowView(for: id))
|
||
|
||
let second = TranscriptEntry(
|
||
id: id,
|
||
content: .assistant(
|
||
ChatMessage(id: id, role: .assistant, text: "First delta", isStreaming: true),
|
||
canFork: false
|
||
)
|
||
)
|
||
update(coordinator, listView, entries: [second])
|
||
|
||
#expect(listView.rowView(for: id) === mounted)
|
||
}
|
||
|
||
@Test
|
||
func everyStreamingRevisionIsAppliedImmediately() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let first = streamingAssistant(id: id, text: "0")
|
||
update(coordinator, listView, entries: [first])
|
||
|
||
for revision in 1...20 {
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [streamingAssistant(id: id, text: String(revision))]
|
||
)
|
||
let latest = try #require(listView.content.first)
|
||
guard case .assistant(let message, _) = latest.content else {
|
||
Issue.record("Expected the assistant row")
|
||
return
|
||
}
|
||
#expect(message.text == String(revision))
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func userBubbleUsesMarkdownHugsShortContentAndKeepsTheTrailingGutter() throws {
|
||
for isCompact in [true, false] {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [user(id: id, text: "**Hi**")],
|
||
isCompact: isCompact
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let shortBubble = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserBubble")
|
||
)
|
||
let shortMarkdown = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserMarkdown")
|
||
)
|
||
let shortFrame = row.convert(shortBubble.bounds, from: shortBubble)
|
||
let horizontalInset = isCompact ? Metrics.Space.l : Metrics.Space.xl
|
||
#expect(abs(shortFrame.maxX - (row.bounds.maxX - horizontalInset)) <= 1)
|
||
#expect(shortFrame.width < row.bounds.width / 2)
|
||
#expect(
|
||
abs(shortBubble.bounds.width - shortMarkdown.bounds.width - Metrics.Space.l * 2)
|
||
<= 1
|
||
)
|
||
#expect(
|
||
abs(shortBubble.bounds.height - shortMarkdown.bounds.height - Metrics.Space.m * 2)
|
||
<= 1
|
||
)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [
|
||
user(
|
||
id: id,
|
||
text: String(repeating: "A long Markdown sentence that wraps. ", count: 30)
|
||
)
|
||
],
|
||
isCompact: isCompact
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let longBubble = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserBubble")
|
||
)
|
||
let longFrame = row.convert(longBubble.bounds, from: longBubble)
|
||
let gutter = isCompact ? Metrics.Chat.compactGutter : Metrics.Chat.gutter
|
||
let maximumWidth = row.bounds.width - horizontalInset * 2 - gutter
|
||
#expect(longFrame.width > shortFrame.width)
|
||
#expect(longFrame.width <= maximumWidth + 1)
|
||
#expect(abs(longFrame.maxX - (row.bounds.maxX - horizontalInset)) <= 1)
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func userBubbleUsesASubtleAccentFillInLightAndDarkAppearances() throws {
|
||
for appearanceName in [NSAppearance.Name.aqua, .darkAqua] {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
listView.appearance = NSAppearance(named: appearanceName)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let id = UUID()
|
||
|
||
update(coordinator, listView, entries: [user(id: id, text: "Hi")])
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let bubble = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserBubble")
|
||
)
|
||
bubble.updateLayer()
|
||
let color = try #require(bubble.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)))
|
||
#expect(abs(color.alphaComponent - 0.10) < 0.002)
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func mountedTranscriptRefreshesWhenSystemAppearanceChanges() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 260)
|
||
let id = UUID()
|
||
let entry = user(id: id, text: "The same mounted row follows system appearance.")
|
||
|
||
listView.appearance = NSAppearance(named: .aqua)
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [entry],
|
||
colorScheme: .light
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
let lightRow = try #require(listView.rowView(for: id))
|
||
let lightBubble = try #require(
|
||
descendant(in: lightRow, identifier: "Transcript.UserBubble")
|
||
)
|
||
lightBubble.updateLayer()
|
||
let lightLayerColor = try #require(
|
||
lightBubble.layer?.backgroundColor.flatMap(NSColor.init(cgColor:))
|
||
)
|
||
let light = try #require(lightLayerColor.usingColorSpace(.deviceRGB))
|
||
let lightFrame = listView.rectForRow(with: id)
|
||
|
||
listView.appearance = NSAppearance(named: .darkAqua)
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [entry],
|
||
colorScheme: .dark
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
let darkRow = try #require(listView.rowView(for: id))
|
||
let darkBubble = try #require(
|
||
descendant(in: darkRow, identifier: "Transcript.UserBubble")
|
||
)
|
||
darkBubble.updateLayer()
|
||
let darkLayerColor = try #require(
|
||
darkBubble.layer?.backgroundColor.flatMap(NSColor.init(cgColor:))
|
||
)
|
||
let dark = try #require(darkLayerColor.usingColorSpace(.deviceRGB))
|
||
|
||
#expect(abs(light.alphaComponent - 0.10) < 0.002)
|
||
#expect(abs(dark.alphaComponent - 0.10) < 0.002)
|
||
#expect(listView.rectForRow(with: id).size == lightFrame.size)
|
||
}
|
||
|
||
@Test
|
||
func userChipKeepsTheOldHalfQuaternaryFillInLightAndDarkAppearances() throws {
|
||
for appearanceName in [NSAppearance.Name.aqua, .darkAqua] {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
listView.appearance = NSAppearance(named: appearanceName)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let id = UUID()
|
||
let chip = ContextChipReference(kind: .file, label: "notes.md")
|
||
let entry = TranscriptEntry(
|
||
id: id,
|
||
content: .user(
|
||
ChatMessage(
|
||
id: id,
|
||
role: .user,
|
||
text: "Review this",
|
||
contextChips: [chip]
|
||
),
|
||
canEdit: false
|
||
)
|
||
)
|
||
|
||
update(coordinator, listView, entries: [entry])
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let chipView = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserChip")
|
||
)
|
||
let chipContent = try #require(
|
||
descendant(in: chipView, identifier: "Transcript.UserChipContent")
|
||
)
|
||
chipView.updateLayer()
|
||
let color = try #require(
|
||
chipView.layer?.backgroundColor.flatMap(NSColor.init(cgColor:))
|
||
)
|
||
#expect(abs(color.alphaComponent - 0.049) < 0.002)
|
||
#expect(abs(chipView.bounds.width - chipContent.bounds.width - 12) <= 1)
|
||
#expect(abs(chipView.bounds.height - chipContent.bounds.height - 6) <= 1)
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func terminalSuggestionUsesTheSameSurfaceAsThePlanCard() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
listView.appearance = NSAppearance(named: .aqua)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let id = UUID()
|
||
let suggestion = SuggestedCommand(
|
||
id: id,
|
||
command: "pwd",
|
||
explanation: "Show the current directory."
|
||
)
|
||
let entry = TranscriptEntry(
|
||
id: id,
|
||
content: .command(suggestion)
|
||
)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [entry],
|
||
showsTerminalActions: true,
|
||
actions: .init(canDeliverCommand: { true })
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let card = try #require(descendant(in: row, identifier: "Transcript.CommandCard"))
|
||
let content = try #require(
|
||
descendant(in: card, identifier: "Transcript.CommandCardContent") as? NSStackView
|
||
)
|
||
let commandBackground = try #require(
|
||
descendant(in: card, identifier: "Transcript.CommandCardCommandBackground")
|
||
)
|
||
let command = try #require(
|
||
descendant(in: card, identifier: "Transcript.CommandCardCommand") as? NSTextField
|
||
)
|
||
let explanation = try #require(
|
||
descendant(in: card, identifier: "Transcript.CommandCardExplanation") as? NSTextField
|
||
)
|
||
let insert = try #require(
|
||
descendant(in: card, identifier: "Transcript.CommandCardInsert") as? NSButton
|
||
)
|
||
let run = try #require(
|
||
descendant(in: card, identifier: "Transcript.CommandCardRun") as? NSButton
|
||
)
|
||
|
||
card.displayIfNeeded()
|
||
commandBackground.displayIfNeeded()
|
||
let cardColor = try #require(card.layer?.backgroundColor.flatMap(NSColor.init(cgColor:)))
|
||
let commandColor = try #require(
|
||
commandBackground.layer?.backgroundColor.flatMap(NSColor.init(cgColor:))
|
||
)
|
||
// A detached layer can resolve a named AppKit color as clear while the
|
||
// test list has no window. The mounted app resolves it through the
|
||
// owning appearance; this test owns the card hierarchy, not AppKit's
|
||
// detached dynamic-color behavior.
|
||
#expect(cardColor.alphaComponent == 0 || cardColor.alphaComponent == 1)
|
||
#expect(card.layer?.cornerRadius == Metrics.Radius.large)
|
||
#expect(card.layer?.borderWidth == 1)
|
||
#expect(commandColor.alphaComponent == 0)
|
||
#expect(content.spacing == Metrics.Space.m)
|
||
#expect(command.font?.fontDescriptor.symbolicTraits.contains(.monoSpace) == true)
|
||
#expect(command.font?.pointSize == NSFont.systemFontSize - 1)
|
||
#expect(explanation.font?.pointSize == NSFont.smallSystemFontSize)
|
||
#expect(insert.isBordered)
|
||
#expect(run.isBordered)
|
||
}
|
||
|
||
@Test
|
||
func nativeTranscriptProminentButtonsInvertTheirForegroundWithAppearance() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let presentation = TranscriptPresentationState()
|
||
let userID = UUID()
|
||
let plan = AgentPlan(
|
||
title: "Implementation plan",
|
||
body: "Apply the requested change.",
|
||
scratchPath: "/tmp/plan.md"
|
||
)
|
||
let commandID = UUID()
|
||
let markdownID = UUID()
|
||
listView.appearance = NSAppearance(named: .aqua)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 520, height: 2_000)
|
||
presentation.beginEditingMessage(userID, text: "Edited message")
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [
|
||
user(id: userID, text: "Original message"),
|
||
TranscriptEntry(
|
||
id: plan.id,
|
||
content: .plan(AgentPlanCard(plan: plan))
|
||
),
|
||
TranscriptEntry(
|
||
id: commandID,
|
||
content: .command(
|
||
SuggestedCommand(
|
||
id: commandID,
|
||
command: "pwd",
|
||
explanation: "Show the current directory."
|
||
)
|
||
)
|
||
),
|
||
assistant(
|
||
id: markdownID,
|
||
text: "```bash\nrm -rf /tmp/termind-prominent-button-test\n```"
|
||
),
|
||
],
|
||
presentation: presentation,
|
||
isCompact: false,
|
||
showsTerminalActions: true,
|
||
actions: .init(canDeliverCommand: { true })
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let buttonLocations = [
|
||
(userID, "Transcript.UserEditorSend"),
|
||
(plan.id, "Transcript.PlanImplement"),
|
||
(commandID, "Transcript.CommandCardRun"),
|
||
(markdownID, "Transcript.MarkdownShellRun"),
|
||
]
|
||
let buttons = try buttonLocations.map { rowID, identifier in
|
||
let row = try #require(listView.rowView(for: rowID))
|
||
return try #require(descendant(in: row, identifier: identifier) as? NSButton)
|
||
}
|
||
|
||
for button in buttons {
|
||
try expectProminentForeground(button, component: 1)
|
||
}
|
||
|
||
listView.appearance = NSAppearance(named: .darkAqua)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
for button in buttons {
|
||
try expectProminentForeground(button, component: 0)
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func userImagesAndChipsSitOutsideAndAboveTheTextBubble() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let id = UUID()
|
||
let data = try #require(
|
||
Data(
|
||
base64Encoded:
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL7WQAAAABJRU5ErkJggg=="
|
||
))
|
||
let attachment = AgentImageAttachment(
|
||
label: "Dropped image",
|
||
mimeType: "image/png",
|
||
data: data,
|
||
pixelWidth: 16,
|
||
pixelHeight: 16
|
||
)
|
||
let chip = ContextChipReference(
|
||
id: attachment.id,
|
||
kind: .image,
|
||
label: attachment.label
|
||
)
|
||
let entry = TranscriptEntry(
|
||
id: id,
|
||
content: .user(
|
||
ChatMessage(
|
||
id: id,
|
||
role: .user,
|
||
text: "what’s this",
|
||
contextChips: [chip],
|
||
images: [attachment]
|
||
),
|
||
canEdit: false
|
||
)
|
||
)
|
||
|
||
update(coordinator, listView, entries: [entry])
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let image = try #require(descendant(in: row, identifier: "Transcript.UserImage"))
|
||
let chipView = try #require(descendant(in: row, identifier: "Transcript.UserChip"))
|
||
let bubble = try #require(descendant(in: row, identifier: "Transcript.UserBubble"))
|
||
#expect(!image.isDescendant(of: bubble))
|
||
#expect(!chipView.isDescendant(of: bubble))
|
||
|
||
let imageFrame = row.convert(image.bounds, from: image)
|
||
let chipFrame = row.convert(chipView.bounds, from: chipView)
|
||
let bubbleFrame = row.convert(bubble.bounds, from: bubble)
|
||
if row.isFlipped {
|
||
#expect(imageFrame.maxY <= chipFrame.minY)
|
||
#expect(chipFrame.maxY <= bubbleFrame.minY)
|
||
} else {
|
||
#expect(imageFrame.minY >= chipFrame.maxY)
|
||
#expect(chipFrame.minY >= bubbleFrame.maxY)
|
||
}
|
||
#expect(abs(imageFrame.maxX - bubbleFrame.maxX) <= 1)
|
||
#expect(abs(chipFrame.maxX - bubbleFrame.maxX) <= 1)
|
||
}
|
||
|
||
@Test
|
||
func userImageUsesAReliableSquareCenterCropForEverySourceRatio() throws {
|
||
let data = try #require(
|
||
Data(
|
||
base64Encoded:
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL7WQAAAABJRU5ErkJggg=="
|
||
))
|
||
for (pixelWidth, pixelHeight) in [(400, 200), (200, 400)] {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let id = UUID()
|
||
let attachment = AgentImageAttachment(
|
||
label: "preview.png",
|
||
mimeType: "image/png",
|
||
data: data,
|
||
pixelWidth: pixelWidth,
|
||
pixelHeight: pixelHeight
|
||
)
|
||
let entry = TranscriptEntry(
|
||
id: id,
|
||
content: .user(
|
||
ChatMessage(
|
||
id: id,
|
||
role: .user,
|
||
text: "preview",
|
||
images: [attachment]
|
||
),
|
||
canEdit: false
|
||
)
|
||
)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [entry],
|
||
isCompact: false
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let image = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserImage")
|
||
)
|
||
let preview = try #require(
|
||
descendant(in: image, identifier: "Transcript.UserImagePreview")
|
||
)
|
||
#expect(image.bounds.width == Metrics.Chat.imageTile)
|
||
#expect(image.bounds.height == Metrics.Chat.imageTile)
|
||
#expect(preview.bounds.width == preview.bounds.height)
|
||
#expect(preview.layer?.contentsGravity == .resizeAspectFill)
|
||
#expect(preview.layer?.masksToBounds == true)
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func userEditorExpandsFromAShortBubbleAndGrowsWithItsDraft() throws {
|
||
for isCompact in [true, false] {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let presentation = TranscriptPresentationState()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
let entry = user(id: id, text: "Hi")
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [entry],
|
||
presentation: presentation,
|
||
isCompact: isCompact
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
let row = try #require(listView.rowView(for: id))
|
||
let restingBubble = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserBubble")
|
||
)
|
||
let restingWidth = restingBubble.bounds.width
|
||
|
||
presentation.beginEditingMessage(id, text: "Hi")
|
||
coordinator.refreshDisplayedRow(id, in: listView)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let shortEditor = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserEditor")
|
||
)
|
||
let shortScroll = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserEditorScroll")
|
||
)
|
||
let editingBubble = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserBubble")
|
||
)
|
||
let minimumEditorWidth: CGFloat = isCompact ? 160 : 200
|
||
#expect(shortEditor.bounds.width >= minimumEditorWidth)
|
||
#expect(editingBubble.bounds.width > restingWidth)
|
||
#expect(
|
||
abs(editingBubble.bounds.width - shortEditor.bounds.width - Metrics.Space.l * 2)
|
||
<= 1
|
||
)
|
||
#expect(shortScroll.bounds.height >= 44)
|
||
|
||
presentation.setMessageEditText(
|
||
(1...12).map { "Expanded line \($0)" }.joined(separator: "\n"),
|
||
for: id
|
||
)
|
||
coordinator.refreshDisplayedRow(id, in: listView)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let tallScroll = try #require(
|
||
descendant(in: row, identifier: "Transcript.UserEditorScroll")
|
||
)
|
||
#expect(tallScroll.bounds.height > shortScroll.bounds.height)
|
||
#expect(tallScroll.bounds.height <= 160)
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func streamingTextHasNoSupplementalCaretRowAndEmptyTextKeepsWorkingActivity() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
|
||
update(coordinator, listView, entries: [streamingAssistant(id: id, text: "Hello")])
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let markdown = try #require(
|
||
descendant(in: row, identifier: "Transcript.AssistantMarkdown")
|
||
)
|
||
let markdownFrame = row.convert(markdown.bounds, from: markdown)
|
||
#expect(abs(markdownFrame.maxY - row.bounds.maxY) <= 1)
|
||
#expect(descendant(in: row, identifier: "Transcript.WorkingActivity") == nil)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [streamingAssistant(id: id, text: "")]
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
#expect(descendant(in: row, identifier: "Transcript.WorkingActivity") != nil)
|
||
}
|
||
|
||
@Test
|
||
func workingActivityUsesAFullBoundsMovingNotchMask() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [streamingAssistant(id: id, text: "")],
|
||
reduceMotion: false
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: id))
|
||
let activity = try #require(
|
||
descendant(in: row, identifier: "Transcript.WorkingActivity")
|
||
)
|
||
activity.layoutSubtreeIfNeeded()
|
||
let mask = try #require(activity.layer?.mask)
|
||
#expect(mask.frame == activity.bounds)
|
||
#expect(mask.animation(forKey: "scan") != nil)
|
||
}
|
||
|
||
@Test
|
||
func singleRunningToolTitleStillInstallsTheScanningMask() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let runID = UUID()
|
||
let invocation = AgentToolInvocation(
|
||
runID: runID,
|
||
call: AgentToolCall(
|
||
id: "call", name: "lookup", arguments: .object([:])
|
||
)
|
||
)
|
||
var card = AgentToolCard(
|
||
invocation: invocation,
|
||
descriptor: AgentToolDescriptor(
|
||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||
target: .local, safety: .locallyReadOnly
|
||
)
|
||
)
|
||
card.state = .running
|
||
let entry = TranscriptEntry(
|
||
id: runID,
|
||
content: .work(
|
||
AgentWorkGroup(
|
||
run: AgentRunSummary(
|
||
id: runID, state: .running, startedAt: .now
|
||
),
|
||
items: [
|
||
.tools(
|
||
AgentToolBatch(
|
||
cards: [card], runID: runID, reasoning: nil
|
||
))
|
||
],
|
||
orderedAt: .now,
|
||
anchorMessageID: nil,
|
||
appearsAfterAnswer: false,
|
||
activeReasoningBlockID: nil
|
||
))
|
||
)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [entry],
|
||
reduceMotion: false
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: runID))
|
||
let title = try #require(
|
||
descendant(in: row, identifier: "Transcript.ScanningTitle")
|
||
)
|
||
#expect(title.layer?.mask?.animation(forKey: "scan") != nil)
|
||
}
|
||
|
||
@Test
|
||
func messageRowsTrackMouseMovementAcrossContentGapAndSideWhitespace() throws {
|
||
for isCompact in [true, false] {
|
||
for entry in [
|
||
user(id: UUID(), text: "User message"),
|
||
assistant(id: UUID(), text: "Assistant message"),
|
||
] {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [entry],
|
||
isCompact: isCompact
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let row = try #require(listView.rowView(for: entry.id))
|
||
let actions = try #require(
|
||
descendant(in: row, identifier: "Transcript.MessageActions")
|
||
)
|
||
let contentIdentifier =
|
||
entry.kind == .user
|
||
? "Transcript.UserBubble" : "Transcript.AssistantMarkdown"
|
||
let content = try #require(
|
||
descendant(in: row, identifier: contentIdentifier)
|
||
)
|
||
let contentFrame = row.convert(content.bounds, from: content)
|
||
let actionsFrame = row.convert(actions.bounds, from: actions)
|
||
|
||
row.updateTrackingAreas()
|
||
let tracking = try #require(
|
||
row.trackingAreas.first { $0.options.contains(.mouseMoved) }
|
||
)
|
||
#expect(tracking.options.contains(.inVisibleRect))
|
||
#expect(row.bounds.width == listView.bounds.width)
|
||
#expect(contentFrame.minX > row.bounds.minX)
|
||
#expect(contentFrame.maxX < row.bounds.maxX)
|
||
#expect(
|
||
abs(
|
||
actionsFrame.minY - contentFrame.maxY
|
||
- Metrics.Chat.blockSpacing
|
||
) <= 1
|
||
)
|
||
|
||
let gap = CGPoint(x: row.bounds.midX, y: contentFrame.maxY + 1)
|
||
row.mouseMoved(with: try #require(pointerEvent(at: gap)))
|
||
#expect(actions.alphaValue == 1)
|
||
|
||
row.mouseExited(with: try #require(pointerEvent()))
|
||
#expect(actions.alphaValue == 0)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Test
|
||
func hoverSurvivesTheRefreshThatAddsCompletedMessageActions() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let presentation = TranscriptPresentationState()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [streamingAssistant(id: id, text: "Working")],
|
||
presentation: presentation
|
||
)
|
||
let row = try #require(listView.rowView(for: id) as? TranscriptAssistantRow)
|
||
let event = try #require(pointerEvent())
|
||
row.mouseEntered(with: event)
|
||
|
||
update(
|
||
coordinator,
|
||
listView,
|
||
entries: [assistant(id: id, text: "Finished")],
|
||
presentation: presentation
|
||
)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let actions = try #require(
|
||
descendant(in: row, identifier: "Transcript.MessageActions")
|
||
)
|
||
#expect(actions.alphaValue == 1)
|
||
|
||
row.mouseExited(with: event)
|
||
#expect(actions.alphaValue == 0)
|
||
row.mouseEntered(with: event)
|
||
#expect(actions.alphaValue == 1)
|
||
}
|
||
|
||
@Test
|
||
func preparingAHoveredRowForReuseClearsItsActionVisibility() throws {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let id = UUID()
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 800)
|
||
|
||
update(coordinator, listView, entries: [assistant(id: id, text: "Finished")])
|
||
let row = try #require(listView.rowView(for: id) as? TranscriptAssistantRow)
|
||
let actions = try #require(
|
||
descendant(in: row, identifier: "Transcript.MessageActions")
|
||
)
|
||
row.mouseEntered(with: try #require(pointerEvent()))
|
||
#expect(actions.alphaValue == 1)
|
||
|
||
row.prepareForReuse()
|
||
|
||
#expect(actions.alphaValue == 0)
|
||
}
|
||
|
||
@Test
|
||
func disclosureRefreshRebuildsTheVisiblePlanAndItsHeight() {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let presentation = TranscriptPresentationState()
|
||
let plan = AgentPlan(
|
||
title: "Implementation plan",
|
||
body: String(repeating: "A detailed paragraph that wraps over several lines.\n\n", count: 24),
|
||
scratchPath: "/tmp/plan.md"
|
||
)
|
||
let entry = TranscriptEntry(
|
||
id: plan.id,
|
||
content: .plan(AgentPlanCard(plan: plan))
|
||
)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 420, height: 1_200)
|
||
update(coordinator, listView, entries: [entry], presentation: presentation)
|
||
listView.layoutSubtreeIfNeeded()
|
||
let collapsedHeight = listView.rectForRow(with: plan.id).height
|
||
|
||
presentation.disclosures.toggleDisclosure(.plan(plan.id))
|
||
coordinator.refreshDisplayedRow(plan.id, in: listView)
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
#expect(listView.rectForRow(with: plan.id).height > collapsedHeight)
|
||
}
|
||
|
||
@Test
|
||
func nativeRowsUseTheirMeasuredHeights() {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let short = TranscriptEntry(id: UUID(), content: .empty)
|
||
let tallID = UUID()
|
||
let tall = TranscriptEntry(
|
||
id: tallID,
|
||
content: .assistant(
|
||
ChatMessage(
|
||
id: tallID, role: .assistant,
|
||
text: String(repeating: "A wrapping transcript line. ", count: 80)
|
||
),
|
||
canFork: false
|
||
),
|
||
hasLeadingSpacing: true
|
||
)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 400)
|
||
|
||
update(coordinator, listView, entries: [short, tall])
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let shortHeight = listView.rectForRow(with: short.id).height
|
||
let tallHeight = listView.rectForRow(with: tall.id).height
|
||
#expect(shortHeight > 0)
|
||
#expect(tallHeight > 96)
|
||
#expect(tallHeight > shortHeight)
|
||
}
|
||
|
||
@Test
|
||
func explicitHeightRowsDoNotInstallVisibleMeasurementPrototypes() {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let item = TranscriptEntry(id: UUID(), content: .empty)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 400)
|
||
|
||
update(coordinator, listView, entries: [item])
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let rows = listView.subviews.compactMap { $0 as? ListRowView }
|
||
#expect(rows.count == 1)
|
||
#expect(rows.allSatisfy { !$0.isHidden })
|
||
}
|
||
|
||
@Test
|
||
func aStableBlockGrowthRemeasuresBeforePlacingTheFollowingBlock() {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let growingID = UUID()
|
||
let followingID = UUID()
|
||
let short = assistant(id: growingID, text: "Short block")
|
||
let tall = assistant(
|
||
id: growingID,
|
||
text: String(repeating: "Expanded work detail wraps over many lines. ", count: 80)
|
||
)
|
||
let following = TranscriptEntry(id: followingID, content: .empty)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 1_200)
|
||
|
||
update(coordinator, listView, entries: [short, following])
|
||
let shortHeight = listView.rectForRow(with: growingID).height
|
||
|
||
update(coordinator, listView, entries: [tall, following])
|
||
listView.layoutSubtreeIfNeeded()
|
||
|
||
let grownFrame = listView.rectForRow(with: growingID)
|
||
let followingFrame = listView.rectForRow(with: followingID)
|
||
#expect(grownFrame.height > shortHeight)
|
||
#expect(followingFrame.minY == grownFrame.maxY)
|
||
#expect(listView.rowView(for: growingID)?.placedFrame == grownFrame)
|
||
#expect(listView.rowView(for: followingID)?.placedFrame == followingFrame)
|
||
}
|
||
|
||
@Test
|
||
func followingTheBottomUsesTheRemeasuredBlockHeight() {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let growingID = UUID()
|
||
let leading = TranscriptEntry(id: UUID(), content: .empty)
|
||
let short = assistant(id: growingID, text: "Short block")
|
||
let tall = assistant(
|
||
id: growingID,
|
||
text: String(repeating: "Expanded work detail wraps over many lines. ", count: 80)
|
||
)
|
||
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 180)
|
||
|
||
update(coordinator, listView, entries: [leading, short])
|
||
listView.scrollToBottom(animated: false)
|
||
let oldMaximum = listView.maximumContentOffset.y
|
||
|
||
update(coordinator, listView, entries: [leading, tall])
|
||
|
||
#expect(listView.maximumContentOffset.y > oldMaximum)
|
||
#expect(listView.contentOffset.y == listView.maximumContentOffset.y)
|
||
let lastFrame = listView.rectForRow(with: growingID)
|
||
#expect(lastFrame.maxY + listView.bottomInset == listView.contentSize.height)
|
||
}
|
||
|
||
@Test
|
||
func blockGrowthDoesNotPullAReaderBackFromHistory() {
|
||
let coordinator = TranscriptListView.Coordinator()
|
||
let listView = coordinator.makeListView()
|
||
let growingID = UUID()
|
||
let short = assistant(id: growingID, text: "Short block")
|
||
let tall = assistant(
|
||
id: growingID,
|
||
text: String(repeating: "Expanded work detail wraps over many lines. ", count: 80)
|
||
)
|
||
let history = (0..<10).map { _ in
|
||
TranscriptEntry(id: UUID(), content: .empty)
|
||
}
|
||
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 180)
|
||
|
||
update(coordinator, listView, entries: history + [short])
|
||
listView.setContentOffset(CGPoint(x: 0, y: 40), animated: false)
|
||
listView.layoutSubtreeIfNeeded()
|
||
let readerOffset = listView.contentOffset
|
||
|
||
update(coordinator, listView, entries: history + [tall])
|
||
|
||
#expect(listView.contentOffset == readerOffset)
|
||
#expect(!listView.isScrolledToBottom(tolerance: Metrics.Space.l))
|
||
}
|
||
|
||
private func update(
|
||
_ coordinator: TranscriptListView.Coordinator,
|
||
_ listView: TranscriptListView.NativeListView,
|
||
entries: [TranscriptEntry],
|
||
presentation: TranscriptPresentationState = TranscriptPresentationState(),
|
||
isCompact: Bool = true,
|
||
showsTerminalActions: Bool = false,
|
||
actions: TranscriptListActions = .init(),
|
||
reduceMotion: Bool = true,
|
||
colorScheme: ColorScheme = .light
|
||
) {
|
||
coordinator.update(
|
||
listView: listView,
|
||
entries: entries,
|
||
latestUserMessageID: nil,
|
||
configuration: .init(
|
||
isCompact: isCompact,
|
||
showsTerminalActions: showsTerminalActions,
|
||
isConversationStreaming: false,
|
||
locale: Locale(identifier: "en"),
|
||
reduceMotion: reduceMotion,
|
||
colorScheme: colorScheme
|
||
),
|
||
actions: actions,
|
||
presentation: presentation
|
||
)
|
||
}
|
||
|
||
private func assistant(id: UUID, text: String) -> TranscriptEntry {
|
||
TranscriptEntry(
|
||
id: id,
|
||
content: .assistant(
|
||
ChatMessage(id: id, role: .assistant, text: text),
|
||
canFork: false
|
||
)
|
||
)
|
||
}
|
||
|
||
private func user(id: UUID, text: String) -> TranscriptEntry {
|
||
TranscriptEntry(
|
||
id: id,
|
||
content: .user(
|
||
ChatMessage(id: id, role: .user, text: text),
|
||
canEdit: false
|
||
)
|
||
)
|
||
}
|
||
|
||
private func streamingAssistant(id: UUID, text: String) -> TranscriptEntry {
|
||
TranscriptEntry(
|
||
id: id,
|
||
content: .assistant(
|
||
ChatMessage(id: id, role: .assistant, text: text, isStreaming: true),
|
||
canFork: false
|
||
)
|
||
)
|
||
}
|
||
|
||
private func pointerEvent(at location: CGPoint = .zero) -> NSEvent? {
|
||
NSEvent.mouseEvent(
|
||
with: .mouseMoved,
|
||
location: location,
|
||
modifierFlags: [],
|
||
timestamp: 0,
|
||
windowNumber: 0,
|
||
context: nil,
|
||
eventNumber: 0,
|
||
clickCount: 0,
|
||
pressure: 0
|
||
)
|
||
}
|
||
|
||
private func descendant(in root: NSView, identifier: String) -> NSView? {
|
||
var queue = root.subviews
|
||
while let view = queue.first {
|
||
queue.removeFirst()
|
||
if view.identifier?.rawValue == identifier { return view }
|
||
queue.append(contentsOf: view.subviews)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func expectProminentForeground(_ button: NSButton, component: CGFloat) throws {
|
||
let titleColor = try #require(
|
||
button.attributedTitle.attribute(
|
||
.foregroundColor,
|
||
at: 0,
|
||
effectiveRange: nil
|
||
) as? NSColor
|
||
)
|
||
let alternateTitleColor = try #require(
|
||
button.attributedAlternateTitle.attribute(
|
||
.foregroundColor,
|
||
at: 0,
|
||
effectiveRange: nil
|
||
) as? NSColor
|
||
)
|
||
for color in [try #require(button.contentTintColor), titleColor, alternateTitleColor] {
|
||
let rgb = try #require(color.usingColorSpace(.deviceRGB))
|
||
#expect(abs(rgb.redComponent - component) < 0.001)
|
||
#expect(abs(rgb.greenComponent - component) < 0.001)
|
||
#expect(abs(rgb.blueComponent - component) < 0.001)
|
||
#expect(abs(rgb.alphaComponent - 1) < 0.001)
|
||
}
|
||
}
|
||
|
||
private func work(id: UUID, state: AgentRunState) -> TranscriptEntry {
|
||
let start = Date(timeIntervalSince1970: 1_000)
|
||
return TranscriptEntry(
|
||
id: id,
|
||
content: .work(
|
||
AgentWorkGroup(
|
||
run: AgentRunSummary(
|
||
id: id,
|
||
state: state,
|
||
startedAt: start,
|
||
finishedAt: state == .completed ? start.addingTimeInterval(2) : nil
|
||
),
|
||
items: [
|
||
.message(
|
||
ChatMessage(
|
||
role: .assistant,
|
||
text: String(repeating: "Visible work detail. ", count: 20)
|
||
))
|
||
],
|
||
orderedAt: start,
|
||
anchorMessageID: nil,
|
||
appearsAfterAnswer: false,
|
||
activeReasoningBlockID: nil
|
||
))
|
||
)
|
||
}
|
||
}
|