Fix transcript resizing and support 360-point content widths

This commit is contained in:
Kaede Akino committed 2026-09-18 10:04:27 +08:00
1 parent 21597fecaf
commit 299bc10d50
11 files changed
+495 -72

No files matched your search

+11
View File
@@ -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.
+2 -42
View File
@@ -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
+2 -2
View File
@@ -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) }
+6 -5
View File
@@ -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
+32
View File
@@ -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
+22 -8
View File
@@ -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 =
+34 -14
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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)
)
}
}
+141
View File
@@ -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<T: NSView>(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)
}
}
}
}
+168
View File
@@ -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()