Fix transcript resizing and support 360-point content widths
This commit is contained in:
1 parent
21597fecaf
commit
299bc10d50
11 files changed
+495
-72
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user