diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 8eb4cf0f..f6fa176e 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -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 */; diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3dd1ba60..87ccb32b 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 48be5e6c..b8553b0c 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -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()) { + $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) diff --git a/Termind/Features/AI/MessageViews.swift b/Termind/Features/AI/MessageViews.swift index b57cb3e0..20998eb9 100644 --- a/Termind/Features/AI/MessageViews.swift +++ b/Termind/Features/AI/MessageViews.swift @@ -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) } } diff --git a/Termind/Features/AI/TranscriptListView.swift b/Termind/Features/AI/TranscriptListView.swift new file mode 100644 index 00000000..00bb8655 --- /dev/null +++ b/Termind/Features/AI/TranscriptListView.swift @@ -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 { + guard case .timeline(let item) = content else { return [] } + var result: Set = [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) { + 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 { + 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 + 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 = [] + 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 { + var onIntrinsicSizeChange: (@MainActor () -> Void)? + + override func invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + guard let onIntrinsicSizeChange else { return } + DispatchQueue.main.async(execute: onIntrinsicSizeChange) + } +} diff --git a/Termind/Features/AI/TranscriptScrollCoordination.swift b/Termind/Features/AI/TranscriptScrollCoordination.swift deleted file mode 100644 index 56bf1db4..00000000 --- a/Termind/Features/AI/TranscriptScrollCoordination.swift +++ /dev/null @@ -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 - } -} diff --git a/TermindTests/TranscriptListViewTests.swift b/TermindTests/TranscriptListViewTests.swift new file mode 100644 index 00000000..f04eae1e --- /dev/null +++ b/TermindTests/TranscriptListViewTests.swift @@ -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) + } +} diff --git a/TermindTests/TranscriptScrollCoordinationTests.swift b/TermindTests/TranscriptScrollCoordinationTests.swift deleted file mode 100644 index a06a81bf..00000000 --- a/TermindTests/TranscriptScrollCoordinationTests.swift +++ /dev/null @@ -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 - ) - } -}