Adopt ListViewKit for assistant transcript

This commit is contained in:
Kaede Akino committed 2026-08-28 16:18:44 +08:00
1 parent d8ac6442d0
commit 75a23ebe7f
8 files changed
+743 -399

No files matched your search

+17
View File
@@ -16,6 +16,7 @@
TRMD00000000000000100006 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100015 /* ZIPFoundation */; };
TRMD00000000000000100007 /* GhosttyKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100016 /* GhosttyKit */; };
TRMD00000000000000100008 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100017 /* MarkdownView */; };
TRMD00000000000000100009 /* ListViewKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100018 /* ListViewKit */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -93,6 +94,7 @@
files = (
TRMD00000000000000100005 /* ZIPFoundation in Frameworks */,
TRMD00000000000000100008 /* MarkdownView in Frameworks */,
TRMD00000000000000100009 /* ListViewKit in Frameworks */,
TRMD00000000000000100001 /* CSSH2 in Frameworks */,
TRMD00000000000000100007 /* GhosttyKit in Frameworks */,
TRMD00000000000000100002 /* GhosttyTerminal in Frameworks */,
@@ -168,6 +170,7 @@
TRMD00000000000000100014 /* OpenSSL */,
TRMD00000000000000100015 /* ZIPFoundation */,
TRMD00000000000000100017 /* MarkdownView */,
TRMD00000000000000100018 /* ListViewKit */,
);
productName = Termind;
productReference = TRMD00000000000000000001 /* Termind.app */;
@@ -264,6 +267,7 @@
TRMD00000000000000100023 /* XCRemoteSwiftPackageReference "openssl-spm" */,
TRMD00000000000000100024 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
TRMD00000000000000100025 /* XCRemoteSwiftPackageReference "MarkdownView" */,
TRMD00000000000000100026 /* XCRemoteSwiftPackageReference "ListViewKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = TRMD00000000000000000040 /* Products */;
@@ -618,6 +622,14 @@
minimumVersion = 4.3.1;
};
};
TRMD00000000000000100026 /* XCRemoteSwiftPackageReference "ListViewKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Lakr233/ListViewKit";
requirement = {
kind = exactVersion;
version = 4.3.2;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -656,6 +668,11 @@
package = TRMD00000000000000100025 /* XCRemoteSwiftPackageReference "MarkdownView" */;
productName = MarkdownView;
};
TRMD00000000000000100018 /* ListViewKit */ = {
isa = XCSwiftPackageProductDependency;
package = TRMD00000000000000100026 /* XCRemoteSwiftPackageReference "ListViewKit" */;
productName = ListViewKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = TRMD00000000000000000090 /* Project object */;
@@ -1,5 +1,5 @@
{
"originHash" : "2b3dca4cc1943e866780af4287f00d9358f15a96bc20e632a8cdd365ec2cf52d",
"originHash" : "d6f7775e55b73f16661cb2d32fed9a81591b83b3b8da1f305f652bdfff758aa8",
"pins" : [
{
"identity" : "highlightr",
@@ -28,6 +28,15 @@
"version" : "2.0.3"
}
},
{
"identity" : "listviewkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/ListViewKit",
"state" : {
"revision" : "c6a067ba837758a50612f88dd1d6bb025175df6c",
"version" : "4.3.2"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
@@ -5,8 +5,6 @@ import SwiftUI
/// Shared by the session Assistant sidebar and full Assistant tab so the two can never drift; only
/// the padding and the provider header differ.
struct ConversationView: View {
private enum ScrollTarget: Hashable { case bottom }
let conversation: ConversationModel
var isCompact: Bool = false
/// Forwarded to the composer. Only the sidebar has a focused pane to
@@ -22,15 +20,9 @@ struct ConversationView: View {
/// before the app starts its first run.
var onSend: (() -> Void)?
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@State private var isAtTranscriptBottom = true
@State private var isFollowingTranscriptBottom = true
@State private var isUserScrollingTranscript = false
@State private var isTranscriptScrollPending = true
@State private var transcriptScrollMetrics: TranscriptScrollMetrics?
@State private var isSuppressingDisclosureLayout = false
@State private var disclosureToggleGeneration = 0
@State private var transcriptPresentation = TranscriptPresentationState()
var body: some View {
VStack(spacing: 0) {
@@ -141,190 +133,62 @@ struct ConversationView: View {
}
private var transcript: some View {
ScrollViewReader { proxy in
ScrollView {
// No rules between turns. A bubble on one side and unframed
// prose on the other already separate the speakers, and a line
// through every gap turns a conversation into a ledger.
//
// Not lazy, and that is load-bearing. A `LazyVStack` reports an
// *estimated* content height, and collapsing a tall row —
// closing a tool call, or a run ending and folding its work away
// — makes that estimate wrong in a way it will not correct on
// its own. Measured in an `NSHostingView` harness: collapsing a
// 2400pt group inside a lazy stack settled at contentSize 2924
// with the viewport parked at 2324, when the true height was
// 1031. The viewport therefore sat 1300pt past the end of the
// document with nothing built there — a blank transcript that
// stayed blank, because `isBeyondBottom` computed against the
// stale estimate reads false and no repair fires. Neither
// `ScrollViewProxy.scrollTo(id:)` nor
// `ScrollPosition.scrollTo(edge:)` could correct it; only a real
// scroll, which forces re-measurement. The same collapse in a
// plain `VStack` settles at 1031/431 with no repair at all.
//
// The cost is a longer first layout — about 240ms at 300 rows
// against 23ms lazy — while a streaming delta stays under one
// frame either way (6ms against 2.5ms). If opening a very long
// conversation starts to hitch, window the transcript by count
// rather than restoring the laziness.
VStack(alignment: .leading, spacing: Metrics.Chat.turnSpacing) {
if conversation.isEmpty {
emptyState
} else {
ForEach(conversation.timeline) { item in
row(for: item).id(item.id)
}
if conversation.runState == .interrupted {
Label(
"This run was interrupted and was not resumed.",
systemImage: "pause.circle"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Color.clear
.frame(height: 1)
.id(ScrollTarget.bottom)
}
}
// Horizontal padding belongs to the column, not to each turn:
// it is what lines a bubble's trailing edge up with the left
// edge of unframed assistant text.
.padding(.horizontal, isCompact ? Metrics.Space.l : Metrics.Space.xl)
.padding(.vertical, Metrics.Space.xl)
.frame(
maxWidth: isCompact ? .infinity : Metrics.Chat.contentMaxWidth
)
.frame(maxWidth: .infinity)
}
.onScrollGeometryChange(for: TranscriptScrollMetrics.self) { geometry in
TranscriptScrollMetrics(
isAtBottom: geometry.visibleRect.maxY
>= geometry.contentSize.height - Metrics.Space.l,
contentHeight: geometry.contentSize.height,
viewportHeight: geometry.containerSize.height,
visibleMinY: geometry.visibleRect.minY
)
} action: { oldMetrics, newMetrics in
transcriptScrollMetrics = newMetrics
isAtTranscriptBottom = newMetrics.isAtBottom
if isUserScrollingTranscript {
isFollowingTranscriptBottom = newMetrics.isAtBottom
}
switch TranscriptScrollCoordination.adjustment(
from: oldMetrics, to: newMetrics,
isFollowingBottom: isFollowingTranscriptBottom,
isUserScrolling: isUserScrollingTranscript,
isSuppressingDisclosureLayout: isSuppressingDisclosureLayout
) {
case .none:
break
case .followBottomAfterLayout:
// A token only needs a scroll when it actually wraps or
// adds a block. Coalesce that ordinary live growth, but do
// not send disclosure growth through the same path.
requestScrollToBottom()
case .repairBottomImmediately:
repairTranscriptBottom(using: proxy)
}
}
.onScrollPhaseChange { _, phase in
switch phase {
case .tracking, .interacting, .decelerating:
isUserScrollingTranscript = true
if !isAtTranscriptBottom {
isFollowingTranscriptBottom = false
}
case .idle:
if isUserScrollingTranscript {
isFollowingTranscriptBottom = isAtTranscriptBottom
}
isUserScrollingTranscript = false
if transcriptScrollMetrics?.isBeyondBottom == true {
repairTranscriptBottom(using: proxy)
}
case .animating:
// Programmatic scrolling is not an instruction to stop
// following. Only direct user interaction changes it.
break
}
}
// Sending a new prompt is an explicit return to the live edge,
// even if the reader had previously scrolled up through history.
.onChange(of: conversation.messages.last(where: { $0.role == .user })?.id) {
isSuppressingDisclosureLayout = false
requestScrollToBottom(force: true)
}
.task(id: disclosureToggleGeneration) {
guard disclosureToggleGeneration > 0 else { return }
do {
// Text wrapping and images settle a disclosure's contents
// over more than one layout pass. Keep all of those passes
// classified as the same user toggle rather than as live
// model output.
try await Task.sleep(for: .milliseconds(120))
} catch {
return
}
if transcriptScrollMetrics?.isAtBottom == false {
isFollowingTranscriptBottom = false
}
isSuppressingDisclosureLayout = false
}
.task(id: isTranscriptScrollPending) {
// One view-owned debounce coalesces rapid layout changes.
// Unlike an unstructured Task per token, it is cancelled with
// the Sidebar and can never retain a stale ScrollViewProxy.
guard isTranscriptScrollPending else { return }
do {
try await Task.sleep(for: .milliseconds(80))
} catch {
return
}
guard isFollowingTranscriptBottom else {
isTranscriptScrollPending = false
return
}
proxy.scrollTo(ScrollTarget.bottom, anchor: .bottom)
isTranscriptScrollPending = false
}
let timeline = conversation.timeline
let items = TranscriptRenderItem.items(
timeline: timeline,
isInterrupted: conversation.runState == .interrupted
)
return TranscriptListView(
items: items,
latestUserMessageID: conversation.messages.last(where: { $0.role == .user })?.id
) { item, disclosureDidToggle in
AnyView(
renderItem(item, onDisclosureToggle: disclosureDidToggle)
.padding(.horizontal, isCompact ? Metrics.Space.l : Metrics.Space.xl)
.padding(.top, item.hasLeadingSpacing ? Metrics.Chat.turnSpacing : 0)
.frame(maxWidth: isCompact ? .infinity : Metrics.Chat.contentMaxWidth)
.frame(maxWidth: .infinity)
.environment(app)
.environment(window)
)
}
}
private func requestScrollToBottom(force: Bool = false) {
guard force || isFollowingTranscriptBottom else { return }
if force { isFollowingTranscriptBottom = true }
isTranscriptScrollPending = true
}
/// Both directions, deliberately. A collapse used to reach the coordinator
/// as an anonymous contraction, so a reader closing a long tool row while
/// the run was still following the live edge was thrown to the bottom of
/// the transcript — the opposite of what closing it asked for.
private func disclosureDidToggle() {
isTranscriptScrollPending = false
isSuppressingDisclosureLayout = true
disclosureToggleGeneration &+= 1
}
private func repairTranscriptBottom(using proxy: ScrollViewProxy) {
isTranscriptScrollPending = false
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
proxy.scrollTo(ScrollTarget.bottom, anchor: .bottom)
.onChange(of: items.reduce(into: Set<UUID>()) {
$0.formUnion($1.presentationIDs)
}, initial: true) { _, validIDs in
transcriptPresentation.prune(validTimelineIDs: validIDs)
}
}
@ViewBuilder
private func row(for item: TimelineItem) -> some View {
private func renderItem(
_ item: TranscriptRenderItem,
onDisclosureToggle: @escaping @MainActor () -> Void
) -> some View {
switch item.content {
case .timeline(let item):
row(for: item, onDisclosureToggle: onDisclosureToggle)
case .empty:
emptyState
case .interrupted:
Label(
"This run was interrupted and was not resumed.",
systemImage: "pause.circle"
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private func row(
for item: TimelineItem,
onDisclosureToggle disclosureDidToggle: @escaping @MainActor () -> Void
) -> some View {
switch item {
case .message(let message) where message.role == .user:
UserMessageView(
message: message, isCompact: isCompact,
presentation: transcriptPresentation,
canEdit: conversation.canEditUserMessage(message.id),
onEdit: { text in
window.editAssistantUserMessage(
@@ -346,22 +210,28 @@ struct ConversationView: View {
}
)
case .tool(let card):
AgentToolRowView(card: card, onDisclosureToggle: disclosureDidToggle)
AgentToolRowView(
card: card, presentation: transcriptPresentation,
onDisclosureToggle: disclosureDidToggle
)
case .work(let group):
AgentWorkGroupView(
group: group, isCompact: isCompact,
presentation: transcriptPresentation,
onDisclosureToggle: disclosureDidToggle
)
case .plan(let card):
AgentPlanRowView(
card: card, conversation: conversation,
presentation: transcriptPresentation,
onDisclosureToggle: disclosureDidToggle
)
case .compaction(let mark):
AgentCompactionRowView(mark: mark)
case .compactionActivity(let activity):
AgentCompactionActivityRowView(
activity: activity, onDisclosureToggle: disclosureDidToggle
activity: activity, presentation: transcriptPresentation,
onDisclosureToggle: disclosureDidToggle
)
}
}
@@ -388,26 +258,22 @@ struct ConversationView: View {
private struct AgentWorkGroupView: View {
let group: AgentWorkGroup
let isCompact: Bool
let presentation: TranscriptPresentationState
let onDisclosureToggle: () -> Void
@State private var isExpanded: Bool
/// Whether the reader has stated a preference for this group. A run ending
/// is otherwise allowed to close a process someone opened deliberately.
@State private var readerDidToggle = false
@Environment(\.locale) private var locale
init(group: AgentWorkGroup, isCompact: Bool, onDisclosureToggle: @escaping () -> Void) {
self.group = group
self.isCompact = isCompact
self.onDisclosureToggle = onDisclosureToggle
_isExpanded = State(
initialValue: AgentWorkPresentation.isExpandedByDefault(
for: group.run.state, appearsAfterAnswer: group.appearsAfterAnswer,
isCompact: isCompact
)
private var defaultExpanded: Bool {
AgentWorkPresentation.isExpandedByDefault(
for: group.run.state, appearsAfterAnswer: group.appearsAfterAnswer,
isCompact: isCompact
)
}
private var isExpanded: Bool {
presentation.isExpanded(group.id, default: defaultExpanded)
}
/// One disclosure for both lives of a run, not one per state. Branching the
/// whole disclosure on `isTerminal` changed its structural identity, so the
/// moment a run finished SwiftUI tore the entire process subtree down and
@@ -417,9 +283,8 @@ private struct AgentWorkGroupView: View {
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
Button {
readerDidToggle = true
onDisclosureToggle()
isExpanded.toggle()
presentation.toggleDisclosure(group.id, default: defaultExpanded)
} label: {
header
}
@@ -444,10 +309,12 @@ private struct AgentWorkGroupView: View {
// Runtime transitions are authoritative only until the reader says
// otherwise. Someone who opened this process to watch it did not
// ask for it to close the instant the run reported success.
guard !readerDidToggle else { return }
isExpanded = AgentWorkPresentation.isExpandedByDefault(
for: state, appearsAfterAnswer: group.appearsAfterAnswer,
isCompact: isCompact
presentation.followDefault(
AgentWorkPresentation.isExpandedByDefault(
for: state, appearsAfterAnswer: group.appearsAfterAnswer,
isCompact: isCompact
),
for: group.id
)
}
.accessibilityElement(children: .contain)
@@ -498,9 +365,15 @@ private struct AgentWorkGroupView: View {
case .message(let message):
processMessage(message)
case .tool(let card):
AgentToolRowView(card: card, onDisclosureToggle: onDisclosureToggle)
AgentToolRowView(
card: card, presentation: presentation,
onDisclosureToggle: onDisclosureToggle
)
case .tools(let batch):
AgentToolBatchView(batch: batch, onDisclosureToggle: onDisclosureToggle)
AgentToolBatchView(
batch: batch, presentation: presentation,
onDisclosureToggle: onDisclosureToggle
)
}
}
}
@@ -549,11 +422,12 @@ private struct AgentWorkGroupView: View {
/// terminal batch changes over to a compact action summary.
private struct AgentToolBatchView: View {
let batch: AgentToolBatch
let presentation: TranscriptPresentationState
let onDisclosureToggle: () -> Void
@Environment(AppModel.self) private var app
@Environment(\.locale) private var locale
@State private var isExpanded = false
private var isExpanded: Bool { presentation.isExpanded(batch.id) }
private var cards: [AgentToolCard] { batch.cards }
@@ -589,7 +463,7 @@ private struct AgentToolBatchView: View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Button {
onDisclosureToggle()
isExpanded.toggle()
presentation.toggleDisclosure(batch.id)
} label: {
HStack(spacing: Metrics.Space.s) {
Image(systemName: AgentToolPresentation.batchSymbol(for: cards))
@@ -625,6 +499,7 @@ private struct AgentToolBatchView: View {
ForEach(cards) { card in
AgentToolRowView(
card: card, showsActivityAnimation: false,
presentation: presentation,
onDisclosureToggle: onDisclosureToggle
)
}
@@ -657,11 +532,12 @@ private struct AgentToolBatchView: View {
private struct AgentToolRowView: View {
let card: AgentToolCard
var showsActivityAnimation = true
let presentation: TranscriptPresentationState
let onDisclosureToggle: () -> Void
@Environment(AppModel.self) private var app
@Environment(\.locale) private var locale
@State private var isExpanded = false
private var isExpanded: Bool { presentation.isExpanded(card.id) }
private var summary: String {
AgentToolPresentation.summary(for: card, hostName: hostName, locale: locale)
@@ -671,7 +547,7 @@ private struct AgentToolRowView: View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
Button {
onDisclosureToggle()
isExpanded.toggle()
presentation.toggleDisclosure(card.id)
} label: {
HStack(spacing: Metrics.Space.s) {
Image(systemName: AgentToolPresentation.symbol(for: card))
@@ -940,9 +816,10 @@ private struct AgentToolDetailView: View {
private struct AgentCompactionActivityRowView: View {
let activity: AgentCompactionActivity
let presentation: TranscriptPresentationState
let onDisclosureToggle: () -> Void
@State private var isExpanded = false
private var isExpanded: Bool { presentation.isExpanded(activity.id) }
private var failure: String? {
guard case .failed(let reason) = activity.state else { return nil }
@@ -966,7 +843,7 @@ private struct AgentCompactionActivityRowView: View {
Button {
guard failure != nil else { return }
onDisclosureToggle()
isExpanded.toggle()
presentation.toggleDisclosure(activity.id)
} label: {
HStack(spacing: Metrics.Space.s) {
if failure == nil {
@@ -1223,13 +1100,14 @@ struct AgentTaskCapsuleView: View {
private struct AgentPlanRowView: View {
let card: AgentPlanCard
let conversation: ConversationModel
let presentation: TranscriptPresentationState
let onDisclosureToggle: () -> Void
@Environment(AppModel.self) private var app
/// Collapsed until the reader opens it. A plan is long by construction, and
/// one that unfurled on arrival would push the conversation it belongs to
/// off the screen at the moment the reader wants to see both.
@State private var isExpanded = false
private var isExpanded: Bool { presentation.isExpanded(card.id) }
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.s) {
@@ -1274,7 +1152,7 @@ private struct AgentPlanRowView: View {
// an 8pt chevron would be a poor return on having read that far.
Button {
onDisclosureToggle()
isExpanded = true
if !isExpanded { presentation.toggleDisclosure(card.id) }
} label: {
rendered
.frame(maxHeight: Metrics.Chat.planPreviewHeight, alignment: .top)
@@ -1301,7 +1179,7 @@ private struct AgentPlanRowView: View {
private var header: some View {
Button {
onDisclosureToggle()
isExpanded.toggle()
presentation.toggleDisclosure(card.id)
} label: {
HStack(spacing: Metrics.Space.s) {
Image(systemName: symbol)
+11 -10
View File
@@ -10,17 +10,19 @@ import SwiftUI
struct UserMessageView: View {
let message: ChatMessage
var isCompact = false
let presentation: TranscriptPresentationState
var canEdit = false
var onEdit: ((String) -> Void)?
@State private var isHovering = false
@State private var isEditing = false
@State private var editText = ""
@State private var bubbleWidth: CGFloat = 0
@State private var hasMarkedText = false
@State private var attachmentOpenError: String?
@FocusState private var editFocused: Bool
private var isEditing: Bool { presentation.isEditingMessage(message.id) }
private var editText: String { presentation.messageEditText(message.id) }
private var bubbleWidth: CGFloat { presentation.messageBubbleWidth(message.id) }
var body: some View {
VStack(alignment: .trailing, spacing: Metrics.Chat.blockSpacing) {
HStack(alignment: .top, spacing: 0) {
@@ -59,8 +61,7 @@ struct UserMessageView: View {
}
private func beginEdit() {
editText = message.text
isEditing = true
presentation.beginEditingMessage(message.id, text: message.text)
editFocused = true
}
@@ -105,7 +106,7 @@ struct UserMessageView: View {
// Capture the resting bubble, not the editor. The edit control can
// then occupy the same silhouette instead of accepting the list's
// full-width proposal and turning into a second composer.
if !isEditing { bubbleWidth = width }
if !isEditing { presentation.setMessageBubbleWidth(width, for: message.id) }
}
}
@@ -139,7 +140,7 @@ struct UserMessageView: View {
private var editor: some View {
VStack(alignment: .trailing, spacing: Metrics.Space.s) {
TextEditor(text: $editText)
TextEditor(text: presentation.messageEditBinding(for: message.id))
.scrollContentBackground(.hidden)
.font(.body)
.focused($editFocused)
@@ -159,12 +160,12 @@ struct UserMessageView: View {
return .handled
}
.onKeyPress(.escape, phases: .down) { _ in
isEditing = false
presentation.endEditingMessage(message.id)
return .handled
}
.onAppear { editFocused = true }
HStack(spacing: Metrics.Space.s) {
Button("Cancel") { isEditing = false }
Button("Cancel") { presentation.endEditingMessage(message.id) }
.buttonStyle(.plain)
.foregroundStyle(.secondary)
Button("Send", action: commitEdit)
@@ -191,7 +192,7 @@ struct UserMessageView: View {
private func commitEdit() {
let text = editText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isEditing = false
presentation.endEditingMessage(message.id)
onEdit?(text)
}
}
@@ -0,0 +1,422 @@
import AppKit
import ListViewKit
import Observation
import SwiftUI
/// The stable list item handed to ListViewKit. Empty and interrupted states are
/// rows as well, so every transcript state follows the same sizing and scrolling
/// path instead of layering SwiftUI content over the native list.
nonisolated struct TranscriptRenderItem: Identifiable, Hashable, Sendable {
nonisolated enum Content: Hashable, Sendable {
case timeline(TimelineItem)
case empty
case interrupted
}
let id: UUID
let content: Content
let hasLeadingSpacing: Bool
init(id: UUID, content: Content, hasLeadingSpacing: Bool = false) {
self.id = id
self.content = content
self.hasLeadingSpacing = hasLeadingSpacing
}
private static let emptyID = UUID(uuidString: "00E48921-70E7-493A-9EB3-44727BC2145B")!
private static let interruptedID = UUID(uuidString: "FD8D34B6-E81F-4DC8-91E8-479AD9ACF5CB")!
static func items(timeline: [TimelineItem], isInterrupted: Bool) -> [Self] {
if timeline.isEmpty {
return [.init(id: emptyID, content: .empty)]
}
var result = timeline.enumerated().map { index, item in
Self(
id: item.id,
content: .timeline(item),
hasLeadingSpacing: index > 0
)
}
if isInterrupted {
result.append(.init(
id: interruptedID,
content: .interrupted,
hasLeadingSpacing: true
))
}
return result
}
/// Disclosure and editing state can also belong to rows nested inside a
/// work group. Keeping their IDs here lets a surface discard state only
/// after the corresponding transcript content really disappears.
var presentationIDs: Set<UUID> {
guard case .timeline(let item) = content else { return [] }
var result: Set<UUID> = [item.id]
guard case .work(let group) = item else { return result }
for workItem in group.items {
result.insert(workItem.id)
if case .tools(let batch) = workItem {
result.formUnion(batch.cards.map(\.id))
}
}
return result
}
}
/// Row interaction state must outlive a recycled NSHostingView. It belongs to
/// one transcript surface: the compact sidebar and full Assistant may show the
/// same conversation without forcing one another's disclosures open.
@MainActor
@Observable
final class TranscriptPresentationState {
private struct DisclosureState {
var isExpanded: Bool
var readerDidToggle: Bool
}
private var disclosureStates: [UUID: DisclosureState] = [:]
private struct MessageEditState {
var isEditing = false
var text = ""
var bubbleWidth: CGFloat = 0
}
private var messageEdits: [UUID: MessageEditState] = [:]
func isExpanded(_ id: UUID, default defaultValue: Bool = false) -> Bool {
disclosureStates[id]?.isExpanded ?? defaultValue
}
func readerDidToggle(_ id: UUID) -> Bool {
disclosureStates[id]?.readerDidToggle == true
}
func toggleDisclosure(_ id: UUID, default defaultValue: Bool = false) {
let current = disclosureStates[id]?.isExpanded ?? defaultValue
disclosureStates[id] = .init(isExpanded: !current, readerDidToggle: true)
}
func followDefault(_ expanded: Bool, for id: UUID) {
guard disclosureStates[id]?.readerDidToggle != true else { return }
disclosureStates[id] = .init(isExpanded: expanded, readerDidToggle: false)
}
func prune(validTimelineIDs: Set<UUID>) {
disclosureStates = disclosureStates.filter { validTimelineIDs.contains($0.key) }
messageEdits = messageEdits.filter { validTimelineIDs.contains($0.key) }
}
func isEditingMessage(_ id: UUID) -> Bool {
messageEdits[id]?.isEditing == true
}
func messageEditText(_ id: UUID) -> String {
messageEdits[id]?.text ?? ""
}
func messageBubbleWidth(_ id: UUID) -> CGFloat {
messageEdits[id]?.bubbleWidth ?? 0
}
func beginEditingMessage(_ id: UUID, text: String) {
var state = messageEdits[id] ?? .init()
state.isEditing = true
state.text = text
messageEdits[id] = state
}
func endEditingMessage(_ id: UUID) {
var state = messageEdits[id] ?? .init()
state.isEditing = false
messageEdits[id] = state
}
func setMessageEditText(_ text: String, for id: UUID) {
var state = messageEdits[id] ?? .init()
state.text = text
messageEdits[id] = state
}
func setMessageBubbleWidth(_ width: CGFloat, for id: UUID) {
var state = messageEdits[id] ?? .init()
state.bubbleWidth = width
messageEdits[id] = state
}
func messageEditBinding(for id: UUID) -> Binding<String> {
Binding(
get: { [weak self] in self?.messageEditText(id) ?? "" },
set: { [weak self] in self?.setMessageEditText($0, for: id) }
)
}
}
nonisolated struct TranscriptListUpdatePlan: Equatable, Sendable {
var replacesAll = false
var updates: [TranscriptRenderItem] = []
var appends: [TranscriptRenderItem] = []
static func make(
old: [TranscriptRenderItem], new: [TranscriptRenderItem]
) -> Self {
guard !old.isEmpty else { return .init(replacesAll: true) }
let sharedCount = min(old.count, new.count)
let oldSharedIDs = old.prefix(sharedCount).map(\.id)
let newSharedIDs = new.prefix(sharedCount).map(\.id)
guard oldSharedIDs == newSharedIDs, new.count >= old.count else {
return .init(replacesAll: true)
}
return .init(
updates: zip(old, new).compactMap { $0 == $1 ? nil : $1 },
appends: Array(new.dropFirst(old.count))
)
}
}
/// SwiftUI ownership outside, AppKit virtualization inside. The representable
/// deliberately exposes only content and rendering; ListViewKit never leaks into
/// the rest of the Assistant feature.
struct TranscriptListView: NSViewRepresentable {
typealias NSViewType = ListView<TranscriptRenderItem>
typealias Renderer = @MainActor (
TranscriptRenderItem, @escaping @MainActor () -> Void
) -> AnyView
let items: [TranscriptRenderItem]
let latestUserMessageID: UUID?
let renderer: Renderer
@Environment(\.locale) private var locale
@Environment(\.colorScheme) private var colorScheme
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSViewType {
context.coordinator.makeListView()
}
func updateNSView(_ listView: NSViewType, context: Context) {
context.coordinator.update(
listView: listView,
items: items,
latestUserMessageID: latestUserMessageID,
renderer: renderer,
environment: .init(
locale: locale,
colorScheme: colorScheme
)
)
}
@MainActor
final class Coordinator {
struct EnvironmentSnapshot {
let locale: Locale
let colorScheme: ColorScheme
}
private var renderer: Renderer?
private var environment: EnvironmentSnapshot?
private var content: [TranscriptRenderItem] = []
private var latestUserMessageID: UUID?
private weak var listView: NSViewType?
private var pendingInvalidations: Set<UUID> = []
private var pendingInvalidationMayFollow = false
private var invalidationScheduled = false
private var disclosureSuppressedUntil: [UUID: UInt64] = [:]
private let measurementController = NSHostingController(
rootView: AnyView(EmptyView())
)
func makeListView() -> NSViewType {
let listView = NSViewType()
self.listView = listView
listView.estimatedRowHeight = 96
listView.topInset = Metrics.Space.xl
listView.bottomInset = Metrics.Space.xl
listView.rowAnimator = ListBouncyAnimator(style: .regular)
listView.rows {
ListRow(TranscriptHostingRow.self)
.estimatedHeight(96)
.height { [weak self] item, context in
self?.measuredHeight(for: item, width: context.width) ?? 96
}
.configure { [weak self] row, item, context in
self?.configure(row, with: item, purpose: context.purpose)
}
}
return listView
}
func update(
listView: NSViewType,
items newItems: [TranscriptRenderItem],
latestUserMessageID newLatestUserMessageID: UUID?,
renderer: @escaping Renderer,
environment: EnvironmentSnapshot
) {
self.renderer = renderer
self.environment = environment
let isFirstUpdate = content.isEmpty
let userSentMessage = latestUserMessageID != nil
&& latestUserMessageID != newLatestUserMessageID
let shouldFollow = isFirstUpdate || userSentMessage
|| (listView.isScrolledToBottom(tolerance: Metrics.Space.l)
&& !listView.isUserInteractingWithScroll)
let plan = TranscriptListUpdatePlan.make(old: content, new: newItems)
if plan.replacesAll {
listView.apply(newItems)
} else {
for item in plan.updates { listView.update(item) }
if !plan.appends.isEmpty { listView.append(contentsOf: plan.appends) }
}
content = newItems
latestUserMessageID = newLatestUserMessageID
guard shouldFollow else { return }
DispatchQueue.main.async { [weak listView] in
guard let listView, !listView.isUserInteractingWithScroll else { return }
listView.scrollToBottom(animated: false)
}
}
private func configure(
_ row: TranscriptHostingRow,
with item: TranscriptRenderItem,
purpose: ListRowPurpose
) {
guard let renderer, let environment else { return }
let disclosureToggle: @MainActor () -> Void = { [weak self] in
self?.suppressFollowing(for: item.id)
self?.invalidate(item.id, mayFollow: false)
}
let content = renderer(item, disclosureToggle)
.environment(\.locale, environment.locale)
.environment(\.colorScheme, environment.colorScheme)
let sizeChange: (@MainActor () -> Void)?
switch purpose {
case .display:
sizeChange = { [weak self] in
guard let self else { return }
self.invalidate(
item.id,
mayFollow: !self.isFollowingSuppressed(for: item.id)
)
}
case .measurement:
sizeChange = nil
}
row.configure(
id: item.id,
rootView: AnyView(content),
onIntrinsicSizeChange: sizeChange
)
}
private func measuredHeight(
for item: TranscriptRenderItem,
width: CGFloat
) -> CGFloat {
guard let renderer, let environment, width > 0 else { return 96 }
let content = renderer(item, {})
.environment(\.locale, environment.locale)
.environment(\.colorScheme, environment.colorScheme)
measurementController.rootView = AnyView(content)
return ceil(measurementController.sizeThatFits(in: CGSize(
width: width,
height: .greatestFiniteMagnitude
)).height)
}
private func suppressFollowing(for id: UUID) {
disclosureSuppressedUntil[id] = DispatchTime.now().uptimeNanoseconds
+ 250_000_000
}
private func isFollowingSuppressed(for id: UUID) -> Bool {
guard let deadline = disclosureSuppressedUntil[id] else { return false }
if DispatchTime.now().uptimeNanoseconds < deadline { return true }
disclosureSuppressedUntil[id] = nil
return false
}
private func invalidate(_ id: UUID, mayFollow: Bool) {
pendingInvalidations.insert(id)
pendingInvalidationMayFollow = pendingInvalidationMayFollow || mayFollow
guard !invalidationScheduled else { return }
invalidationScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.invalidationScheduled = false
let ids = self.pendingInvalidations
self.pendingInvalidations.removeAll()
let listView = self.listView
let shouldFollow = self.pendingInvalidationMayFollow
&& listView?.isScrolledToBottom(tolerance: Metrics.Space.l) == true
&& listView?.isUserInteractingWithScroll == false
self.pendingInvalidationMayFollow = false
for id in ids {
listView?.invalidateLayout(forRowWith: id)
}
if shouldFollow {
DispatchQueue.main.async { [weak listView] in
guard let listView, !listView.isUserInteractingWithScroll else { return }
listView.scrollToBottom(animated: false)
}
}
}
}
}
}
@MainActor
private final class TranscriptHostingRow: ListRowView {
private let hostingView = TranscriptIntrinsicHostingView(rootView: AnyView(EmptyView()))
private var representedID: UUID?
override init(frame: CGRect) {
super.init(frame: frame)
hostingView.translatesAutoresizingMaskIntoConstraints = false
addSubview(hostingView)
NSLayoutConstraint.activate([
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingView.topAnchor.constraint(equalTo: topAnchor),
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
override func prepareForReuse() {
super.prepareForReuse()
representedID = nil
hostingView.onIntrinsicSizeChange = nil
hostingView.rootView = AnyView(EmptyView())
}
func configure(
id: UUID,
rootView: AnyView,
onIntrinsicSizeChange: (@MainActor () -> Void)?
) {
representedID = id
hostingView.onIntrinsicSizeChange = onIntrinsicSizeChange
hostingView.rootView = rootView
}
}
@MainActor
private final class TranscriptIntrinsicHostingView: NSHostingView<AnyView> {
var onIntrinsicSizeChange: (@MainActor () -> Void)?
override func invalidateIntrinsicContentSize() {
super.invalidateIntrinsicContentSize()
guard let onIntrinsicSizeChange else { return }
DispatchQueue.main.async(execute: onIntrinsicSizeChange)
}
}
@@ -1,58 +0,0 @@
import CoreGraphics
/// The geometry that matters when deciding whether a transcript layout change
/// should move the reader. Keeping this independent of SwiftUI makes the
/// distinction between following live content and merely repairing an invalid
/// offset explicit and testable.
nonisolated struct TranscriptScrollMetrics: Equatable, Sendable {
let isAtBottom: Bool
let contentHeight: CGFloat
let viewportHeight: CGFloat
let visibleMinY: CGFloat
var isBeyondBottom: Bool {
visibleMinY > max(0, contentHeight - viewportHeight) + 1
}
}
nonisolated enum TranscriptScrollAdjustment: Equatable, Sendable {
case none
case followBottomAfterLayout
case repairBottomImmediately
}
nonisolated enum TranscriptScrollCoordination {
static func adjustment(
from old: TranscriptScrollMetrics,
to new: TranscriptScrollMetrics,
isFollowingBottom: Bool,
isUserScrolling: Bool,
isSuppressingDisclosureLayout: Bool
) -> TranscriptScrollAdjustment {
guard !isUserScrolling else { return .none }
let contentShrank = new.contentHeight < old.contentHeight - 0.5
let viewportChanged = abs(new.viewportHeight - old.viewportHeight) > 0.5
// A contraction can leave AppKit's old offset below the new end of
// the document. A composer or blocking form changing height has the
// same effect on the viewport.
if contentShrank || viewportChanged {
// An out-of-range viewport is a blank frame whatever caused it, so
// repair it synchronously before asking who asked for the change.
if new.isBeyondBottom { return .repairBottomImmediately }
// Closing a disclosure is navigation, exactly like opening one: the
// reader asked to see less, not to be taken to the live edge.
if isSuppressingDisclosureLayout { return .none }
return isFollowingBottom ? .repairBottomImmediately : .none
}
guard new.contentHeight > old.contentHeight + 0.5 else { return .none }
// Opening a disclosure is navigation, not new conversation output.
// Preserve the row the reader chose instead of pulling them past it.
if isSuppressingDisclosureLayout { return .none }
return isFollowingBottom ? .followBottomAfterLayout : .none
}
}
+188
View File
@@ -0,0 +1,188 @@
import Foundation
import SwiftUI
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 = TranscriptRenderItem(id: id, content: .empty)
let new = TranscriptRenderItem(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 = TranscriptRenderItem.items(
timeline: [firstTimeline], isInterrupted: false
)
let new = TranscriptRenderItem.items(
timeline: [firstTimeline, secondTimeline], isInterrupted: 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) -> TranscriptRenderItem {
TranscriptRenderItem(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 aReaderToggleSurvivesDefaultStateTransitions() {
let id = UUID()
let state = TranscriptPresentationState()
#expect(state.isExpanded(id, default: true))
state.toggleDisclosure(id, default: true)
#expect(!state.isExpanded(id, default: true))
#expect(state.readerDidToggle(id))
state.followDefault(true, for: id)
#expect(!state.isExpanded(id, default: true))
}
@Test
func untouchedDisclosureFollowsItsRuntimeDefault() {
let id = UUID()
let state = TranscriptPresentationState()
state.followDefault(true, for: id)
#expect(state.isExpanded(id))
#expect(!state.readerDidToggle(id))
}
@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 TranscriptListViewBridgeTests {
@Test
func installsTheRegularBouncyAnimator() throws {
let listView = TranscriptListView.Coordinator().makeListView()
let animator = try #require(listView.rowAnimator)
let values = Dictionary(uniqueKeysWithValues:
Mirror(reflecting: animator).children.compactMap { child in
child.label.map { ($0, child.value) }
}
)
#expect(String(describing: type(of: animator)) == "ListBouncyAnimator")
#expect(values["damping"] as? CGFloat == 0.7)
#expect(values["frequency"] as? CGFloat == 1.5)
}
@Test
func hostedRowsUseTheirMeasuredIntrinsicHeights() {
let coordinator = TranscriptListView.Coordinator()
let listView = coordinator.makeListView()
let short = TranscriptRenderItem(id: UUID(), content: .empty)
let tall = TranscriptRenderItem(
id: UUID(), content: .interrupted, hasLeadingSpacing: true
)
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 400)
coordinator.update(
listView: listView,
items: [short, tall],
latestUserMessageID: nil,
renderer: { item, _ in
AnyView(
Text(item.id == short.id
? "Short"
: String(repeating: "A wrapping transcript line. ", count: 80))
.fixedSize(horizontal: false, vertical: true)
.padding(8)
)
},
environment: .init(locale: Locale(identifier: "en"), colorScheme: .light)
)
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)
}
}
@@ -1,113 +0,0 @@
import CoreGraphics
import Testing
@testable import Termind
@Suite
struct TranscriptScrollCoordinationTests {
@Test
func streamingGrowthFollowsWhenReaderIsAtLiveEdge() {
#expect(adjustment(from: metrics(content: 800, viewport: 400, offset: 400),
to: metrics(content: 840, viewport: 400, offset: 400))
== .followBottomAfterLayout)
}
@Test
func disclosureGrowthPreservesTheCurrentViewport() {
#expect(adjustment(from: metrics(content: 800, viewport: 400, offset: 400),
to: metrics(content: 1_200, viewport: 400, offset: 400),
suppressingDisclosure: true) == .none)
}
@Test
func closingADisclosureLeavesAFollowingReaderWhereTheyWere() {
// Following the live edge is not consent to be taken back to it: the
// reader asked this row for less, not for the bottom of the transcript.
#expect(adjustment(from: metrics(content: 1_200, viewport: 400, offset: 300),
to: metrics(content: 700, viewport: 400, offset: 300),
suppressingDisclosure: true) == .none)
}
@Test
func closingADisclosureStillRepairsAViewportPastTheEnd() {
// The one case a toggle cannot be left alone: below the new end of the
// document there is nothing to draw.
#expect(adjustment(from: metrics(content: 1_200, viewport: 400, offset: 800),
to: metrics(content: 700, viewport: 400, offset: 800),
following: false, suppressingDisclosure: true)
== .repairBottomImmediately)
}
@Test
func contentContractionImmediatelyRepairsAFollowingViewport() {
#expect(adjustment(from: metrics(content: 1_200, viewport: 400, offset: 800),
to: metrics(content: 700, viewport: 400, offset: 800))
== .repairBottomImmediately)
}
@Test
func historyReaderKeepsAValidPositionWhenContentContracts() {
#expect(adjustment(from: metrics(content: 1_200, viewport: 400, offset: 300),
to: metrics(content: 900, viewport: 400, offset: 300),
following: false) == .none)
}
@Test
func historyReaderGetsOnlyTheRequiredRepairWhenOldOffsetIsInvalid() {
#expect(adjustment(from: metrics(content: 1_200, viewport: 400, offset: 700),
to: metrics(content: 900, viewport: 400, offset: 700),
following: false) == .repairBottomImmediately)
}
@Test
func blockingSurfaceResizeKeepsAFollowingViewportAtTheBottom() {
#expect(adjustment(from: metrics(content: 800, viewport: 500, offset: 300),
to: metrics(content: 800, viewport: 280, offset: 300))
== .repairBottomImmediately)
}
@Test
func viewportGrowthRepairsAnInvalidHistoryOffset() {
#expect(adjustment(from: metrics(content: 800, viewport: 280, offset: 520),
to: metrics(content: 800, viewport: 500, offset: 520),
following: false) == .repairBottomImmediately)
}
@Test
func directScrollingNeverTriggersProgrammaticAdjustment() {
#expect(adjustment(from: metrics(content: 1_200, viewport: 400, offset: 800),
to: metrics(content: 700, viewport: 400, offset: 800),
userScrolling: true) == .none)
}
@Test
func subpixelLayoutNoiseDoesNotTriggerARepairOrFollowRequest() {
#expect(adjustment(from: metrics(content: 800, viewport: 400, offset: 400),
to: metrics(content: 800.25, viewport: 400.25, offset: 400))
== .none)
}
private func adjustment(
from old: TranscriptScrollMetrics,
to new: TranscriptScrollMetrics,
following: Bool = true,
userScrolling: Bool = false,
suppressingDisclosure: Bool = false
) -> TranscriptScrollAdjustment {
TranscriptScrollCoordination.adjustment(
from: old, to: new, isFollowingBottom: following,
isUserScrolling: userScrolling,
isSuppressingDisclosureLayout: suppressingDisclosure
)
}
private func metrics(
content: CGFloat, viewport: CGFloat, offset: CGFloat
) -> TranscriptScrollMetrics {
TranscriptScrollMetrics(
isAtBottom: offset + viewport >= content - Metrics.Space.l,
contentHeight: content, viewportHeight: viewport,
visibleMinY: offset
)
}
}