From 299bc10d5048b32d23d0db8ee5e0d827cfd6a5ea Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 18 Sep 2026 10:04:27 +0800 Subject: [PATCH] Fix transcript resizing and support 360-point content widths --- Memory/ARCHITECTURE.md | 11 ++ TFM/App/RootView.swift | 44 +---- TFM/App/TermindApp.swift | 4 +- TFM/Design/DesignTokens.swift | 11 +- TFM/Features/AI/TranscriptListView.swift | 32 ++++ TFM/Features/AI/TranscriptNativeRows.swift | 30 +++- TFM/Features/Metrics/MetricsView.swift | 48 ++++-- TFM/Features/QuickOpen/QuickOpenView.swift | 3 +- TFM/Features/Vault/VaultWorkspaceView.swift | 75 +++++++++ TFMTests/ResponsivePageLayoutTests.swift | 141 ++++++++++++++++ TFMTests/TranscriptListViewTests.swift | 168 ++++++++++++++++++++ 11 files changed, 495 insertions(+), 72 deletions(-) create mode 100644 TFM/Features/Vault/VaultWorkspaceView.swift create mode 100644 TFMTests/ResponsivePageLayoutTests.swift diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index f2b8fced..4f5ac1b3 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -62,6 +62,12 @@ cleanup is performed. - Desktop navigation starts with Vault and Assistant, places Metrics above Vault libraries, appends dynamic SSH/SFTP Workspaces, and ends with Settings. Vault owns Hosts, Keychain, Snippets, Known Hosts, Welcome, and Host Editor routes. +- Desktop main pages share a 360-point detail minimum, independent of the + Assistant's 840-point reading maximum. Opening the Navigator grows the window + when necessary. Vault overlays its existing editor below 721 points and keeps + the covered list mounted at its last visible size; Metrics stacks its existing + detail and process views below 630 points. Resizing changes layout, never route, + editor identity, Workspace composition, or monitoring ownership. - Assistant conversations are owned by `AssistantLibraryModel`; they are not Workspace panes. A Workspace owns SSH/SFTP/serial panes and any session-scoped conversation. A session owns live transports and terminal/browser state. @@ -803,3 +809,8 @@ state and survives row reuse; Copy retains the full already-sanitized projection IDs and MCP tools never opt into this policy by name. Long approvals instead retain all live arguments in a scaled 240-point scroll region with actions outside it. Neither preview path reads live skill/memory storage or changes the original protocol result, authorization, or persisted data. + +Desktop transcript width changes reconfigure mounted rows without replacing the +cached list or following the tail. Reused row constraints and Markdown geometry +track the current content width even when message identity and text are unchanged; +parsed Markdown remains reusable independently of width-dependent measurements. diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 1987d935..df9ff24f 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -59,7 +59,7 @@ struct RootView: View { ) .navigationSplitViewColumnWidth( min: Metrics.Window.detailMin, - ideal: Metrics.Window.detailMin + ideal: Metrics.Chat.contentMaxWidth ) } .navigationSplitViewStyle(.balanced) @@ -330,47 +330,7 @@ struct RootView: View { } private var vaultWorkspace: some View { - let showsWorkDetail = window.vaultWorkRoute != nil - - return HStack(spacing: 0) { - VaultView(pages: window.vaultPages) - .frame( - minWidth: Metrics.VaultContent.min, - idealWidth: Metrics.VaultContent.ideal, - maxWidth: .infinity - ) - .layoutPriority(1) - - Divider() - .frame(width: showsWorkDetail ? 1 : 0) - .opacity(showsWorkDetail ? 1 : 0) - - ZStack { - if let route = window.vaultWorkRoute { - VaultWorkDetailView(route: route) - .id(route) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .frame( - minWidth: showsWorkDetail ? Metrics.VaultDetail.min : 0, - idealWidth: showsWorkDetail ? Metrics.VaultDetail.ideal : 0, - maxWidth: showsWorkDetail ? Metrics.VaultDetail.max : 0 - ) - .clipped() - .opacity(showsWorkDetail ? 1 : 0) - .allowsHitTesting(showsWorkDetail) - .accessibilityHidden(!showsWorkDetail) - } - .animation( - Motion.panel(reduceMotion: reduceMotion), - value: window.vaultWorkRoute - ) - .frame( - minWidth: Metrics.VaultContent.min, - maxWidth: .infinity, - maxHeight: .infinity - ) + VaultWorkspaceView() } @ViewBuilder diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 865478c2..6f825cbe 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -151,8 +151,8 @@ struct TermindApp: App { .restorationBehavior(ScreenshotDemoConfiguration.active == nil ? .automatic : .disabled) #endif .defaultSize(width: Metrics.Window.defaultWidth, height: Metrics.Window.defaultHeight) - // The Assistant reading column sets the floor. Opening the sidebar - // lets the native split view grow a window that cannot fit both columns. + // All main pages share one content floor. Opening the sidebar lets + // the native split view grow a window that cannot fit both columns. .windowResizability(.contentMinSize) .commands { TermindCommands(app: app) } diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index c83341ff..b9b86e20 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -272,20 +272,21 @@ nonisolated enum Metrics { } enum VaultContent { - static let min: CGFloat = 420 + static let min: CGFloat = 360 static let ideal: CGFloat = 680 + static let splitMinWidth: CGFloat = min + 1 + Metrics.VaultDetail.min } enum VaultDetail { - static let min: CGFloat = 340 + static let min: CGFloat = 360 static let ideal: CGFloat = 460 static let max: CGFloat = 720 } enum Window { - /// Keep the complete Assistant surface, including its composer, at - /// least as wide as the transcript's reading column. - static let detailMin: CGFloat = Metrics.Chat.contentMaxWidth + /// Every main page can use this width; multi-column pages adapt their + /// layout instead of raising the window's minimum when navigating. + static let detailMin: CGFloat = 360 static let detailMinHeight: CGFloat = 440 /// The native sidebar has an outer inset on Liquid Glass; older diff --git a/TFM/Features/AI/TranscriptListView.swift b/TFM/Features/AI/TranscriptListView.swift index bbdc41d1..742034bf 100644 --- a/TFM/Features/AI/TranscriptListView.swift +++ b/TFM/Features/AI/TranscriptListView.swift @@ -336,6 +336,8 @@ struct TranscriptListView: NSViewRepresentable { private var invalidationScheduled = false private var invalidationMayFollow = false private var autoCollapseCheckScheduled = false + private var widthRefreshScheduled = false + private var displayedWidth: CGFloat? init(mountOwner: PersistentTranscriptListOwner? = nil) { self.mountOwner = mountOwner @@ -354,12 +356,19 @@ struct TranscriptListView: NSViewRepresentable { view.bottomInset = Metrics.Space.xl view.rowAnimator = nil view.postsBoundsChangedNotifications = true + view.postsFrameChangedNotifications = true NotificationCenter.default.addObserver( self, selector: #selector(viewportDidChange), name: NSView.boundsDidChangeNotification, object: view ) + NotificationCenter.default.addObserver( + self, + selector: #selector(viewportDidChange), + name: NSView.frameDidChangeNotification, + object: view + ) view.rows { ListRow(TranscriptEmptyRow.self) .when { $0.kind == .empty } @@ -571,9 +580,32 @@ struct TranscriptListView: NSViewRepresentable { } @objc private func viewportDidChange() { + scheduleWidthRefresh() schedulePendingAutoCollapseCheck() } + private func scheduleWidthRefresh() { + guard let view = listView, view.bounds.width > 0, + view.bounds.width != displayedWidth, !widthRefreshScheduled + else { return } + widthRefreshScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + widthRefreshScheduled = false + guard let view = listView, view.bounds.width > 0, + view.bounds.width != displayedWidth + else { return } + displayedWidth = view.bounds.width + // ListViewKit remeasures heights on resize, but mounted rows + // keep their configuration. Refresh those rows without replacing + // the list or treating the resize as new conversation content. + for entry in content where view.rowView(for: entry.id) != nil { + refreshDisplayedRow(entry.id, in: view) + } + view.layoutSubtreeIfNeeded() + } + } + private func schedulePendingAutoCollapseCheck() { guard !autoCollapseCheckScheduled else { return } autoCollapseCheckScheduled = true diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 9db25022..43788945 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -160,6 +160,7 @@ final class TranscriptRowSizingPool { class TranscriptNativeRow: ListRowView { private let root = NativeStack.vertical(spacing: Metrics.Space.s) private var widthConstraint: NSLayoutConstraint? + private var contentWidthConstraint: NSLayoutConstraint? private var configuration: TranscriptListConfiguration? private var topInset: CGFloat = 0 private var representedID: UUID? @@ -199,6 +200,8 @@ class TranscriptNativeRow: ListRowView { override func prepareForReuse() { super.prepareForReuse() resetHoverState() + contentWidthConstraint?.isActive = false + contentWidthConstraint = nil root.removeAllArrangedSubviews() representedID = nil activeContent = nil @@ -247,6 +250,7 @@ class TranscriptNativeRow: ListRowView { } ) if representedID == entry.id, let activeContent { + contentWidthConstraint?.constant = contentWidth hoverViews.removeAll() if NativeTranscriptFactory.update(activeContent, for: entry, context: context) { synchronizeHoverWithPointer() @@ -256,17 +260,20 @@ class TranscriptNativeRow: ListRowView { let content = NativeTranscriptFactory.view(for: entry, context: context) representedID = entry.id activeContent = content + contentWidthConstraint?.isActive = false root.removeAllArrangedSubviews() if topInset > 0 { root.addArrangedSubview(NativeSpacer(height: topInset)) } let centered = NSView() centered.translatesAutoresizingMaskIntoConstraints = false centered.addSubview(content) content.translatesAutoresizingMaskIntoConstraints = false + let contentWidthConstraint = content.widthAnchor.constraint(equalToConstant: contentWidth) + self.contentWidthConstraint = contentWidthConstraint NSLayoutConstraint.activate([ content.topAnchor.constraint(equalTo: centered.topAnchor), content.bottomAnchor.constraint(equalTo: centered.bottomAnchor), content.centerXAnchor.constraint(equalTo: centered.centerXAnchor), - content.widthAnchor.constraint(equalToConstant: contentWidth), + contentWidthConstraint, ]) root.addArrangedSubview(centered) centered.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true @@ -1851,6 +1858,8 @@ final class NativeMarkdownSegment: NSView { private let markdown = MarkdownTextView() private var content: MarkdownContent? private var measuredSize = NSSize(width: 1, height: 1) + private var lastProposedWidth: CGFloat? + private var lastFillsWidth: Bool? private var heightConstraint: NSLayoutConstraint! private var horizontalScrollViews: [NSScrollView] = [] private var horizontalTrackingAreas: [ObjectIdentifier: NSTrackingArea] = [:] @@ -1898,17 +1907,22 @@ final class NativeMarkdownSegment: NSView { fillsWidth: Bool, immediate: Bool ) { - guard self.content !== content else { return } + let contentChanged = self.content !== content + let availableWidth = max(1, proposedWidth) + guard contentChanged || lastProposedWidth != availableWidth || lastFillsWidth != fillsWidth else { return } let hadContent = self.content != nil self.content = content - let availableWidth = max(1, proposedWidth) + lastProposedWidth = availableWidth + lastFillsWidth = fillsWidth frame.size.width = availableWidth markdown.frame.size.width = availableWidth - if immediate || !hadContent { - markdown.setContentImmediately(content, theme: theme) - } else { - markdown.theme = theme - markdown.setContent(content) + if contentChanged { + if immediate || !hadContent { + markdown.setContentImmediately(content, theme: theme) + } else { + markdown.theme = theme + markdown.setContent(content) + } } let bounded = markdown.boundingSize(for: availableWidth) let measuredWidth = diff --git a/TFM/Features/Metrics/MetricsView.swift b/TFM/Features/Metrics/MetricsView.swift index 3aa564ae..33902e29 100644 --- a/TFM/Features/Metrics/MetricsView.swift +++ b/TFM/Features/Metrics/MetricsView.swift @@ -54,24 +54,44 @@ struct ServerMetricsView: View { } private func detail(_ server: ServerMetric) -> some View { - GeometryReader { geometry in - HStack(alignment: .top, spacing: 16) { - ScrollView { - MetricsServerDetails(server: server, model: app.serverMetrics) - .padding(1) - } - .frame(width: min(340, max(270, geometry.size.width * 0.38))) - .metricsPlainScrollEdges() - MetricsProcessList(server: server) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .padding(16) - } - .frame(minWidth: 630) + MetricsDetailView(server: server, model: app.serverMetrics) } } +struct MetricsDetailView: View { + let server: ServerMetric + let model: ServerMetricsModel + + static let splitMinWidth: CGFloat = 630 + static let compactProcessHeight: CGFloat = 360 + + var body: some View { + GeometryReader { geometry in + let compact = geometry.size.width < Self.splitMinWidth + let layout = + compact + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 16)) + : AnyLayout(HStackLayout(alignment: .top, spacing: 16)) + + ScrollView { + layout { + MetricsServerDetails(server: server, model: model) + .padding(1) + .frame(width: compact ? nil : min(340, max(270, geometry.size.width * 0.38))) + .fixedSize(horizontal: false, vertical: true) + + MetricsProcessList(server: server) + .frame(height: compact ? Self.compactProcessHeight : max(0, geometry.size.height - 32)) + .frame(maxWidth: .infinity) + } + .padding(16) + } + .metricsPlainScrollEdges() + } + } +} + struct ServerMetricsCard: View { let server: ServerMetric let model: ServerMetricsModel diff --git a/TFM/Features/QuickOpen/QuickOpenView.swift b/TFM/Features/QuickOpen/QuickOpenView.swift index b99cb157..2cf9c13d 100644 --- a/TFM/Features/QuickOpen/QuickOpenView.swift +++ b/TFM/Features/QuickOpen/QuickOpenView.swift @@ -34,6 +34,7 @@ struct QuickOpenOverlay: View { y: 12 ) .contentShape(.rect) + .padding(Metrics.Space.xl) } .onExitCommand(perform: close) .accessibilityElement(children: .contain) @@ -78,7 +79,7 @@ struct QuickOpenView: View { shortcutFooter } - .frame(width: Metrics.QuickOpen.width, height: Metrics.QuickOpen.height) + .frame(maxWidth: Metrics.QuickOpen.width, maxHeight: Metrics.QuickOpen.height) .onAppear { searchFocused = true selectedID = results.commands.first?.id diff --git a/TFM/Features/Vault/VaultWorkspaceView.swift b/TFM/Features/Vault/VaultWorkspaceView.swift new file mode 100644 index 00000000..501d5208 --- /dev/null +++ b/TFM/Features/Vault/VaultWorkspaceView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +struct VaultWorkspaceView: View { + @Environment(WindowModel.self) private var window + + var body: some View { + GeometryReader { geometry in + let showsDetail = window.vaultWorkRoute != nil + let showsList = !showsDetail || geometry.size.width >= Metrics.VaultContent.splitMinWidth + + VaultWorkspaceLayout(showsDetail: showsDetail) { + VaultView(pages: window.vaultPages) + .opacity(showsList ? 1 : 0) + .allowsHitTesting(showsList) + .disabled(!showsList) + .accessibilityHidden(!showsList) + + Divider() + .opacity(showsDetail && showsList ? 1 : 0) + + ZStack { + if let route = window.vaultWorkRoute { + VaultWorkDetailView(route: route) + .id(route) + } + } + .clipped() + .opacity(showsDetail ? 1 : 0) + .allowsHitTesting(showsDetail) + .accessibilityHidden(!showsDetail) + } + } + } +} + +/// The children never change identity at the breakpoint. A covered list keeps +/// its last visible size so returning from an editor also keeps its scroll position. +struct VaultWorkspaceLayout: Layout { + var showsDetail: Bool + + struct Cache { + var listSize: CGSize? + } + + func makeCache(subviews: Subviews) -> Cache { Cache() } + + func updateCache(_ cache: inout Cache, subviews: Subviews) {} + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { + guard subviews.count == 3 else { return } + let split = showsDetail && bounds.width >= Metrics.VaultContent.splitMinWidth + let detailWidth = + showsDetail + ? (split ? min(Metrics.VaultDetail.ideal, bounds.width - Metrics.VaultContent.min - 1) : bounds.width) + : 0 + let listWidth = split ? bounds.width - detailWidth - 1 : bounds.width + let listIsVisible = !showsDetail || split + let listSize = listIsVisible ? CGSize(width: listWidth, height: bounds.height) : cache.listSize ?? bounds.size + if listIsVisible { cache.listSize = listSize } + + subviews[0].place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(listSize)) + subviews[1].place( + at: CGPoint(x: bounds.minX + listWidth, y: bounds.minY), anchor: .topLeading, + proposal: ProposedViewSize(width: split ? 1 : 0, height: bounds.height) + ) + subviews[2].place( + at: CGPoint(x: split ? bounds.minX + listWidth + 1 : bounds.minX, y: bounds.minY), anchor: .topLeading, + proposal: ProposedViewSize(width: detailWidth, height: bounds.height) + ) + } +} diff --git a/TFMTests/ResponsivePageLayoutTests.swift b/TFMTests/ResponsivePageLayoutTests.swift new file mode 100644 index 00000000..2672f64e --- /dev/null +++ b/TFMTests/ResponsivePageLayoutTests.swift @@ -0,0 +1,141 @@ +import AppKit +import SwiftUI +import TermindApplication +import Testing + +@testable import Termind + +@MainActor +@Suite(.serialized) +struct ResponsivePageLayoutTests { + @Test + func metricsResizeKeepsTheProcessTableAndMakesAllDetailsScrollable() async throws { + let configuration = ScreenshotDemoConfiguration(scene: .metricsDetail, language: .english) + let model = configuration.makeMetricsModel(hosts: .demo()) + let server = try #require(model.servers.first) + let state = WindowModel() + state.metricsHostID = server.id + let hosting = NSHostingView(rootView: MetricsDetailView(server: server, model: model).environment(state)) + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 900, height: 600), + styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = hosting + defer { + window.close() + model.shutdown() + } + await layout(hosting) + let table = try #require(descendant(in: hosting, ofType: NSTableView.self)) + let processScroll = try #require(table.enclosingScrollView) + let reversedSort = try #require(table.sortDescriptors.first?.reversedSortDescriptor as? NSSortDescriptor) + table.sortDescriptors = [reversedSort] + await layout(hosting) + let sort = table.sortDescriptors + #expect(sort == [reversedSort]) + processScroll.contentView.scroll(to: CGPoint(x: 0, y: 40)) + processScroll.reflectScrolledClipView(processScroll.contentView) + + window.setContentSize(CGSize(width: 360, height: 600)) + await layout(hosting) + let outerScroll = try #require(descendant(in: hosting, ofType: NSScrollView.self)) + #expect(outerScroll !== processScroll) + #expect((outerScroll.documentView?.frame.height ?? 0) > outerScroll.contentView.bounds.height) + #expect(processScroll.frame.height > 200) + #expect(processScroll.contentView.bounds.minY > 0) + #expect(descendant(in: hosting, ofType: NSTableView.self) === table) + #expect(table.sortDescriptors == sort) + + state.metricsProcessQuery = "node" + await layout(hosting) + #expect(table.numberOfRows == 2) + window.setContentSize(CGSize(width: 630, height: 600)) + await layout(hosting) + #expect(descendant(in: hosting, ofType: NSTableView.self) === table) + #expect(table.numberOfRows == 2) + #expect(table.sortDescriptors == sort) + #expect(state.metricsHostID == server.id) + #expect(state.metricsProcessQuery == "node") + } + + @Test + func vaultResizeKeepsEditorDraftAndParksTheCoveredList() async throws { + let list = Probe() + let editor = Probe() + let host = NSHostingView(rootView: Harness(list: list, editor: editor, showsDetail: true)) + host.frame = CGRect(x: 0, y: 0, width: 900, height: 600) + await layout(host) + let listView = try #require(list.view) + let editorView = try #require(editor.view) + editorView.stringValue = "Unsaved host name" + let listSize = listView.bounds.size + + host.frame.size.width = 360 + await layout(host) + #expect(editor.view === editorView) + #expect(editorView.stringValue == "Unsaved host name") + #expect(abs(editorView.bounds.width - 360) <= 1) + #expect(listView.bounds.size == listSize) + + host.frame.size.width = 721 + await layout(host) + #expect(editorView.stringValue == "Unsaved host name") + #expect(abs(editorView.bounds.width - 360) <= 1) + #expect(abs(listView.bounds.width - 360) <= 1) + + host.frame.size.width = 360 + await layout(host) + host.rootView = Harness(list: list, editor: editor, showsDetail: false) + await layout(host) + #expect(list.view === listView) + #expect(abs(listView.bounds.width - 360) <= 1) + #expect(list.creations == 1) + #expect(editor.creations == 1) + } + + private func layout(_ view: NSView) async { + view.layoutSubtreeIfNeeded() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + try? await Task.sleep(for: .milliseconds(100)) + view.layoutSubtreeIfNeeded() + } + + private func descendant(in view: NSView, ofType type: T.Type) -> T? { + if let result = view as? T { return result } + return view.subviews.lazy.compactMap { descendant(in: $0, ofType: type) }.first + } + + private final class Probe { + var view: NSTextField? + var creations = 0 + } + + private struct ProbeView: NSViewRepresentable { + let probe: Probe + + func makeNSView(context: Context) -> NSTextField { + let view = NSTextField() + probe.view = view + probe.creations += 1 + return view + } + + func updateNSView(_ view: NSTextField, context: Context) {} + } + + private struct Harness: View { + let list: Probe + let editor: Probe + let showsDetail: Bool + + var body: some View { + VaultWorkspaceLayout(showsDetail: showsDetail) { + ProbeView(probe: list).frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + ProbeView(probe: editor).frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} diff --git a/TFMTests/TranscriptListViewTests.swift b/TFMTests/TranscriptListViewTests.swift index 7dd1f3a8..bf470f34 100644 --- a/TFMTests/TranscriptListViewTests.swift +++ b/TFMTests/TranscriptListViewTests.swift @@ -276,6 +276,42 @@ struct TranscriptListCacheTests { #expect(window.transcriptCache.count == 0) } + @Test + func remountAtANewWidthReflowsWithoutReplacingTheListOrLeavingHistory() async throws { + let owner = PersistentTranscriptListOwner(key: .init(conversationID: UUID(), surface: .fullScreen)) + let first = TranscriptMountView(frame: CGRect(x: 0, y: 0, width: 900, height: 220)) + owner.mount(in: first) + first.layoutSubtreeIfNeeded() + let entries = (0..<8).map { _ in + let message = ChatMessage( + id: UUID(), role: .assistant, + text: String(repeating: "Keep the reader on this conversation while its width changes. ", count: 12) + ) + return TranscriptEntry(id: message.id, content: .assistant(message, canFork: false)) + } + update(owner, entries: entries) + owner.listView.setContentOffset(CGPoint(x: 0, y: 60), animated: false) + owner.listView.layoutSubtreeIfNeeded() + let nativeList = owner.listView + owner.unmount(from: first) + + let second = TranscriptMountView(frame: CGRect(x: 0, y: 0, width: 360, height: 220)) + owner.mount(in: second) + second.layoutSubtreeIfNeeded() + update(owner, entries: entries) + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + second.layoutSubtreeIfNeeded() + + #expect(owner.listView === nativeList) + #expect(!nativeList.isScrolledToBottom(tolerance: Metrics.Space.l)) + #expect(nativeList.contentOffset.y > 0) + let row = try #require(nativeList.rowView(for: entries[0].id)) + #expect(abs(row.bounds.width - 360) <= 1) + #expect(nativeList.rectForRow(with: entries[0].id).height > 200) + } + private func update( _ owner: PersistentTranscriptListOwner, entries: [TranscriptEntry] @@ -300,6 +336,138 @@ struct TranscriptListCacheTests { @MainActor @Suite struct TranscriptListViewBridgeTests { + @Test + func resizingUnchangedMessagesReflowsMountedContentAndMeasuredHeights() async throws { + for isCompact in [false, true] { + let coordinator = TranscriptListView.Coordinator() + let presentation = TranscriptPresentationState() + let listView = coordinator.makeListView() + let id = UUID() + let entry = assistant( + id: id, text: String(repeating: "A transcript paragraph must wrap at the current width. ", count: 16) + ) + listView.frame = CGRect(x: 0, y: 0, width: 900, height: 1_200) + update(coordinator, listView, entries: [entry], presentation: presentation, isCompact: isCompact) + await settleResize(listView) + let row = try #require(listView.rowView(for: id)) + let markdown = try #require(descendant(in: row, identifier: "Transcript.AssistantMarkdown")) + let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self)) + let wideHeight = listView.rectForRow(with: id).height + let wideWidth = markdown.bounds.width + + listView.frame.size.width = 360 + await settleResize(listView) + + #expect(listView.rowView(for: id) === row) + #expect(descendant(in: row, ofType: NativeMarkdownSegment.self) === segment) + #expect(abs(markdown.bounds.width - (360 - (isCompact ? 24 : 32))) <= 1) + #expect(listView.rectForRow(with: id).height > wideHeight) + #expect(segment.bounds.height > 1) + + listView.frame.size.width = 900 + await settleResize(listView) + + #expect(abs(markdown.bounds.width - wideWidth) <= 1) + #expect(abs(listView.rectForRow(with: id).height - wideHeight) <= 1) + } + } + + @Test + func resizingUserBubblesAndStreamingMessagesDoesNotRequireANewSnapshot() async throws { + let coordinator = TranscriptListView.Coordinator() + let presentation = TranscriptPresentationState() + let listView = coordinator.makeListView() + let userID = UUID() + let assistantID = UUID() + let entries = [ + user(id: userID, text: String(repeating: "A long user message. ", count: 20)), + streamingAssistant(id: assistantID, text: String(repeating: "Streaming prose reflows. ", count: 20)), + ] + listView.frame = CGRect(x: 0, y: 0, width: 900, height: 1_200) + update(coordinator, listView, entries: entries, presentation: presentation, isCompact: false) + await settleResize(listView) + let wideHeight = listView.rectForRow(with: assistantID).height + + listView.frame.size.width = 360 + await settleResize(listView) + + let userRow = try #require(listView.rowView(for: userID)) + let bubble = try #require(descendant(in: userRow, identifier: "Transcript.UserBubble")) + #expect(bubble.bounds.width <= 360 - 32 - Metrics.Chat.gutter + 1) + #expect(listView.rectForRow(with: assistantID).height > wideHeight) + #expect(listView.rectForRow(with: assistantID).minY == listView.rectForRow(with: userID).maxY) + } + + private func settleResize(_ listView: TranscriptListView.NativeListView) async { + listView.layoutSubtreeIfNeeded() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + listView.layoutSubtreeIfNeeded() + } + + @Test + func ordinaryScrollingDoesNotReconfigureMessageWidths() async throws { + let coordinator = TranscriptListView.Coordinator() + let listView = coordinator.makeListView() + let id = UUID() + let entries = + [user(id: id, text: "Keep this visible bubble mounted.")] + + (0..<12).map { _ in TranscriptEntry(id: UUID(), content: .empty) } + listView.frame = CGRect(x: 0, y: 0, width: 360, height: 220) + update(coordinator, listView, entries: entries) + listView.setContentOffset(.zero, animated: false) + await settleResize(listView) + let row = try #require(listView.rowView(for: id)) + let bubble = try #require(descendant(in: row, identifier: "Transcript.UserBubble")) + + listView.setContentOffset(CGPoint(x: 0, y: 12), animated: false) + await settleResize(listView) + + #expect(descendant(in: row, identifier: "Transcript.UserBubble") === bubble) + } + + @Test + func resizingMarkdownRecomputesCodeAndTableOverflow() async throws { + let coordinator = TranscriptListView.Coordinator() + let presentation = TranscriptPresentationState() + let listView = coordinator.makeListView() + let id = UUID() + let text = """ + | Key | Value | Previous value | + | --- | --- | --- | + | result | \(String(repeating: "W", count: 64)) | \(String(repeating: "W", count: 64)) | + + ```swift + let result = "\(String(repeating: "w", count: 80))" + ``` + """ + listView.frame = CGRect(x: 0, y: 0, width: 900, height: 800) + update( + coordinator, listView, entries: [assistant(id: id, text: text)], presentation: presentation, + isCompact: false) + await settleResize(listView) + let row = try #require(listView.rowView(for: id)) + let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self)) + + listView.frame.size.width = 360 + await settleResize(listView) + let narrowScrollers = descendants(in: segment, ofType: NSScrollView.self) + #expect(narrowScrollers.count == 2) + #expect(narrowScrollers.allSatisfy { $0.contentView.bounds.width <= 328 }) + for scroller in narrowScrollers { + #expect((scroller.documentView?.frame.width ?? 0) > scroller.contentView.bounds.width) + } + + listView.frame.size.width = 900 + await settleResize(listView) + #expect(descendant(in: row, ofType: NativeMarkdownSegment.self) === segment) + #expect( + descendants(in: segment, ofType: NSScrollView.self).allSatisfy { + $0.contentView.bounds.width > 328 + }) + } + @Test func richTranscriptDoesNotInstallAPerFrameRowAnimator() { let listView = TranscriptListView.Coordinator().makeListView()