Adopt ListViewKit for assistant transcript
This commit is contained in:
1 parent
d8ac6442d0
commit
75a23ebe7f
8 files changed
+743
-399
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user