Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 37 additions & 77 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10677,7 +10677,6 @@ struct VerticalTabsSidebar: View {
// row sitting behind the open menu. See `SidebarShortcutHintFreezePolicy`.
@State private var frozenShortcutHintsTabId: UUID?
@State private var frozenShortcutHintsValue: Bool = false
@State private var workspaceRowsMeasurement: SidebarWorkspaceRowsMeasurement<UUID>?
@State private var pendingSelectedWorkspaceScrollId: UUID?
@State private var collapsedExtensionSidebarSectionIds: Set<String> = []
@State private var extensionSidebarWorktreeCreationInFlightSectionIds: Set<String> = []
Expand Down Expand Up @@ -11201,27 +11200,11 @@ struct VerticalTabsSidebar: View {
viewportHeight: geometryProxy.size.height,
insets: scrollInsets
)
let measuredWorkspaceRowsHeight = workspaceRowsMeasurement?.rowsHeight(
for: renderContext.visibleWorkspaceRowIds
)
// The empty drop/tap area below the last row is sized to the
// remaining viewport so the scroll content fills (but never
// exceeds) the visible height when the rows fit. Sizing it to a
// finite remainder — instead of the old `maxHeight: .infinity` —
// is what keeps the document view from always overflowing, so the
// overlay scroller stays hidden when there is nothing to scroll
// (https://github.com/manaflow-ai/cmux/issues/3241).
let emptyAreaHeight = SidebarWorkspaceScrollLayout.emptyAreaHeight(
contentMinHeight: contentMinHeight,
rowsHeight: measuredWorkspaceRowsHeight
)

ScrollViewReader { scrollProxy in
ScrollView(.vertical) {
workspaceScrollContent(
renderContext: renderContext,
minHeight: contentMinHeight,
emptyAreaHeight: emptyAreaHeight
minHeight: contentMinHeight
)
}
.background(
Expand Down Expand Up @@ -11320,12 +11303,6 @@ struct VerticalTabsSidebar: View {
}
requestSelectedWorkspaceScroll(scrollProxy, renderContext: renderContext)
}
.onChange(of: renderContext.visibleWorkspaceRowIds) { _, _ in
// Drop the stale rows-height measurement so the empty-area
// sizing recomputes for the new row set. The measurement is
// also keyed by workspace ids, so this is belt-and-suspenders.
workspaceRowsMeasurement = nil
}
.onReceive(NotificationCenter.default.publisher(for: .workspaceOrderDidChange)) { notification in
requestSelectedWorkspaceScrollAfterWorkspaceOrderChange(notification)
}
Expand Down Expand Up @@ -11374,21 +11351,6 @@ struct VerticalTabsSidebar: View {
lastSidebarSelectionIndex = index
}
}
.onPreferenceChange(SidebarWorkspaceRowsHeightPreferenceKey.self) { measurement in
guard let measurement else {
workspaceRowsMeasurement = nil
return
}
let nextMeasurement = SidebarWorkspaceRowsMeasurement(
workspaceIds: measurement.workspaceIds,
rowsHeight: max(0, measurement.rowsHeight)
)
if let workspaceRowsMeasurement,
workspaceRowsMeasurement.isEquivalent(to: nextMeasurement) {
return
}
workspaceRowsMeasurement = nextMeasurement
}
}
}
}
Expand Down Expand Up @@ -12498,10 +12460,21 @@ struct VerticalTabsSidebar: View {

private func workspaceScrollContent(
renderContext: WorkspaceListRenderContext,
minHeight: CGFloat,
emptyAreaHeight: CGFloat
minHeight: CGFloat
) -> some View {
VStack(spacing: 0) {
// Rows take their natural height; the empty drop/tap area stretches to
// fill the remaining viewport. SidebarRowsFillLayout sizes that
// remainder from the explicit viewport height (`minHeight`, the floored
// content height from the scroll geometry) rather than from a layout
// proposal, which a vertical ScrollView leaves unspecified in the
// scroll axis. So we never measure the LazyVStack's whole-content height
// into @State. That measurement (a .background GeometryReader writing a
// PreferenceKey) fed a non-converging relayout loop (#2586 / #5764 /
// #5845). The empty area fills exactly to the viewport when rows fit and
// collapses to 0 when they overflow, so the overlay scroller stays
// hidden (#3241) and the blank area below the last row stays a
// drop/tap target.
SidebarRowsFillLayout(viewportHeight: minHeight) {
workspaceRows(renderContext: renderContext)

SidebarEmptyArea(
Expand All @@ -12513,8 +12486,7 @@ struct VerticalTabsSidebar: View {
topDropIndicatorVisible: emptyAreaTopDropIndicatorVisible(),
tabDropDelegate: emptyAreaTabDropDelegate(renderContext: renderContext),
bonsplitDropIndicator: dropIndicatorBinding,
expandsVertically: false,
minimumHeight: emptyAreaHeight
expandsVertically: false
)
}
.frame(minHeight: minHeight, alignment: .top)
Expand Down Expand Up @@ -12552,18 +12524,11 @@ struct VerticalTabsSidebar: View {
}
.padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
let measuredRows = rows
.background {
GeometryReader { proxy in
Color.clear.preference(
key: SidebarWorkspaceRowsHeightPreferenceKey.self,
value: SidebarWorkspaceRowsMeasurement(
workspaceIds: renderContext.visibleWorkspaceRowIds,
rowsHeight: proxy.size.height
)
)
}
}
// No whole-content height measurement here. Reading the LazyVStack's
// total height via a .background GeometryReader (to size the empty area)
// fed a non-converging relayout loop (#2586 / #5764 / #5845). The
// empty-area fill is now handled geometrically by SidebarRowsFillLayout
// in workspaceScrollContent without measuring these rows.

// Gate ONLY the per-row frame-anchor *reader* (the virtualization-defeating
// work) behind the drag-active check, and keep the Bonsplit drop-capture
Expand All @@ -12573,7 +12538,7 @@ struct VerticalTabsSidebar: View {
// the drop NSView, orphaning the in-flight drag. Applying it at the stable outer
// level keeps the NSView identity-stable across gate flips. (#5325 review)
rowsWithGatedDropTargetReader(
rows: measuredRows,
rows: rows,
renderContext: renderContext,
shouldCollect: shouldCollectWorkspaceDropTargets
)
Expand Down Expand Up @@ -15762,7 +15727,7 @@ struct TabItemView: View, Equatable {
fontScale: fontScale,
onFocus: { updateSelection() }
)
.transition(.opacity.combined(with: .move(edge: .top)))
.transition(.opacity)
}
if !metadataBlocks.isEmpty {
SidebarMetadataMarkdownBlocks(
Expand All @@ -15773,7 +15738,7 @@ struct TabItemView: View, Equatable {
fontScale: fontScale,
onFocus: { updateSelection() }
)
.transition(.opacity.combined(with: .move(edge: .top)))
.transition(.opacity)
}
}

Expand All @@ -15788,7 +15753,7 @@ struct TabItemView: View, Equatable {
.lineLimit(1)
.truncationMode(.tail)
}
.transition(.opacity.combined(with: .move(edge: .top)))
.transition(.opacity)
}

if detailVisibility.showsProgress, let progress = workspaceSnapshot.progress {
Expand All @@ -15811,7 +15776,7 @@ struct TabItemView: View, Equatable {
.lineLimit(1)
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
.transition(.opacity)
}

// Branch + directory row
Expand Down Expand Up @@ -15965,9 +15930,12 @@ struct TabItemView: View, Equatable {
.lineLimit(1)
}
}
.animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.latestLog)
.animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.progress != nil)
.animation(.easeInOut(duration: 0.2), value: workspaceSnapshot.metadataBlocks.count)
// No implicit .animation(value:) on agent-mutable fields: animating a
// row-height change interpolates the LazyVStack's measured height over
// every frame of the 0.2s curve, and with dozens of agent sessions some
// row is always animating, so the sidebar-wide layout re-runs at display
// refresh rate (#5764 / #5845). Lazy rows must be height-stable after
// they appear; content changes now apply in one discrete layout pass.
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
Expand Down Expand Up @@ -17467,9 +17435,12 @@ private struct SidebarMetadataMarkdownBlockRow: View {
let fontScale: CGFloat
let onFocus: () -> Void

@State private var renderedMarkdown: AttributedString?

var body: some View {
// Render inline (memoized) so the FIRST render is already attributed.
// Parsing in onAppear into @State performed a guaranteed nil ->
// attributed swap on every first appearance, changing the row's height
// mid-scroll and re-feeding the sidebar-wide layout cycle (#5764).
let renderedMarkdown = SidebarMetadataMarkdownRenderer.rendered(block.markdown)
Group {
if let renderedMarkdown {
Text(renderedMarkdown)
Expand All @@ -17484,22 +17455,11 @@ private struct SidebarMetadataMarkdownBlockRow: View {
.fixedSize(horizontal: false, vertical: true)
.contentShape(Rectangle())
.onTapGesture { onFocus() }
.onAppear(perform: renderMarkdown)
.onChange(of: block.markdown) { _ in
renderMarkdown()
}
}

private var foregroundColor: Color {
isActive ? activeForegroundColor : .secondary
}

private func renderMarkdown() {
renderedMarkdown = try? AttributedString(
markdown: block.markdown,
options: .init(interpretedSyntax: .full)
)
}
}

enum SidebarAutoScrollDirection: Equatable {
Expand Down
64 changes: 64 additions & 0 deletions Sources/SidebarMetadataMarkdownRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

/// Renders sidebar metadata-block markdown with a bounded memo cache so the
/// FIRST render of a row is already attributed.
///
/// The previous flow parsed in the row's `onAppear` into `@State`: every first
/// appearance of every metadata block performed a guaranteed nil -> attributed
/// swap, changing the row's intrinsic height mid-scroll and re-feeding the
/// sidebar-wide layout/measurement cycle
/// (https://github.com/manaflow-ai/cmux/issues/5764,
/// https://github.com/manaflow-ai/cmux/issues/5845). Lazy rows must be
/// height-stable after they appear, and row state belongs in the initializer or
/// the model, not `onAppear`.
///
/// Parsing inline from `body` matches the `SidebarWorkspaceDescriptionText`
/// sibling; the cache keeps repeat body evaluations cheap (agent-heavy rows
/// re-evaluate often) and is bounded so long sessions with churning metadata
/// cannot grow it without limit.
@MainActor
enum SidebarMetadataMarkdownRenderer {
private static var cache: [String: AttributedString?] = [:]
private static var insertionOrder: [String] = []
private static let capacity = 512
/// Only small blocks are rendered as markdown. Metadata markdown is
/// agent/control-socket supplied and uncapped at this boundary. Caching
/// large values would retain hundreds of big payloads after the workspace
/// metadata is overwritten or cleared (memory), and parsing them inline on
/// every body eval would re-run main-actor Markdown parsing under agent
/// churn (CPU). Above this size the block falls back to plain text (the row
/// renders `Text(block.markdown)` on the nil return): no parse, no
/// retention, and still height-stable because the result never changes for
/// a given block. Cached small blocks bound total retained bytes to
/// `capacity * maxCacheableBytes`. A >4 KB sidebar metadata block is already
/// pathological, so plain text is an acceptable degradation.
private static let maxCacheableBytes = 4096

static func rendered(_ markdown: String) -> AttributedString? {
guard markdown.utf8.count <= maxCacheableBytes else {
return nil
}
if let hit = cache[markdown] {
return hit
}
let parsed = parse(markdown)
if insertionOrder.count >= capacity, let oldest = insertionOrder.first {
insertionOrder.removeFirst()
cache.removeValue(forKey: oldest)
}
Comment on lines +21 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 insertionOrder.removeFirst() is O(n) on every eviction because Array shifts all remaining elements. In a high-churn session where metadata blocks are constantly updated, each eviction walks all 512 entries. A ring-buffer eviction index avoids the per-eviction copy entirely while keeping the same [String: AttributedString?] cache dictionary.

Suggested change
private static var cache: [String: AttributedString?] = [:]
private static var insertionOrder: [String] = []
private static let capacity = 512
static func rendered(_ markdown: String) -> AttributedString? {
if let hit = cache[markdown] {
return hit
}
let parsed = try? AttributedString(
markdown: markdown,
options: .init(interpretedSyntax: .full)
)
if insertionOrder.count >= capacity, let oldest = insertionOrder.first {
insertionOrder.removeFirst()
cache.removeValue(forKey: oldest)
}
private static var cache: [String: AttributedString?] = [:]
private static var insertionOrder: [String] = []
private static var evictIndex: Int = 0
private static let capacity = 512
static func rendered(_ markdown: String) -> AttributedString? {
if let hit = cache[markdown] {
return hit
}
let parsed = try? AttributedString(
markdown: markdown,
options: .init(interpretedSyntax: .full)
)
if insertionOrder.count >= capacity {
let oldest = insertionOrder[evictIndex]
cache.removeValue(forKey: oldest)
insertionOrder[evictIndex] = markdown
evictIndex = (evictIndex + 1) % capacity
} else {
insertionOrder.append(markdown)
}

// updateValue, not subscript assignment: with an Optional value type,
// `cache[markdown] = nil` removes the key instead of caching the failed
// parse, so unparseable blocks would re-parse on every body eval and
// append phantom keys to insertionOrder.
cache.updateValue(parsed, forKey: markdown)
insertionOrder.append(markdown)
return parsed
}

private static func parse(_ markdown: String) -> AttributedString? {
try? AttributedString(
markdown: markdown,
options: .init(interpretedSyntax: .full)
)
}
}
69 changes: 69 additions & 0 deletions Sources/SidebarRowsFillLayout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import SwiftUI

/// Lays the sidebar workspace rows out at their natural height, then stretches a
/// trailing empty drop/tap area to fill the remaining viewport — in one geometry
/// pass, with no whole-content height measurement.
///
/// The previous approach measured the `LazyVStack`'s total height via a
/// `.background` `GeometryReader` and routed it through a `PreferenceKey` into
/// `@State` to size a fixed-height empty area. That preference write during
/// layout fed a non-converging relayout transaction
/// (https://github.com/manaflow-ai/cmux/issues/2586,
/// https://github.com/manaflow-ai/cmux/issues/5764,
/// https://github.com/manaflow-ai/cmux/issues/5845).
///
/// This `Layout` takes the viewport height as an explicit input
/// (`viewportHeight`, the floored content height the call site already computes
/// from the scroll geometry) and sizes the empty area from it directly. It does
/// NOT derive the viewport from the layout proposal: a vertical `ScrollView`
/// leaves the scroll-axis height unspecified, and
/// `ProposedViewSize.replacingUnspecifiedDimensions()` would then fall back to a
/// 10pt placeholder, collapsing the empty area to `0` and dropping the blank
/// area below the last row out of the drop/tap target. With the explicit
/// viewport: when the rows fit, rows + empty area exactly fill the viewport (no
/// overflow, overlay scroller stays hidden —
/// https://github.com/manaflow-ai/cmux/issues/3241); when the rows overflow, the
/// empty area is `0` and the document view scrolls. The rows are never measured
/// into SwiftUI state.
///
/// Expects exactly two subviews in order: `[rows, emptyArea]`.
struct SidebarRowsFillLayout: Layout {
/// The floored viewport height available to the scroll content. The empty
/// area fills the remainder of this height below the rows.
let viewportHeight: CGFloat

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let width = proposal.replacingUnspecifiedDimensions().width
let rowsHeight = subviews.first?.sizeThatFits(
ProposedViewSize(width: width, height: nil)
).height ?? 0
// Fill the viewport when the rows are shorter; grow to the rows' natural
// height when they overflow it. Driven by the explicit viewport, not the
// (unspecified in a vertical ScrollView) proposed height.
return CGSize(width: width, height: max(rowsHeight, viewportHeight))
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard let rows = subviews.first else { return }
let rowsHeight = rows.sizeThatFits(
ProposedViewSize(width: bounds.width, height: nil)
).height
Comment on lines +30 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 rows.sizeThatFits is called twice per layout pass — once in sizeThatFits and again in placeSubviews. The Layout protocol's Cache associated type exists precisely to thread the result between the two, avoiding a redundant measurement of the LazyVStack on every layout cycle. Given that LazyVStack measurement was the root of the original livelock, keeping a cached height here is a straightforward improvement.

Suggested change
struct SidebarRowsFillLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let resolved = proposal.replacingUnspecifiedDimensions()
let rowsHeight = subviews.first?.sizeThatFits(
ProposedViewSize(width: resolved.width, height: nil)
).height ?? 0
// Fill the proposed (viewport) height when the rows are shorter; grow to
// the rows' natural height when they overflow it. The parent
// `.frame(minHeight:)` supplies the viewport floor.
return CGSize(width: resolved.width, height: max(rowsHeight, resolved.height))
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard let rows = subviews.first else { return }
let rowsHeight = rows.sizeThatFits(
ProposedViewSize(width: bounds.width, height: nil)
).height
struct SidebarRowsFillLayout: Layout {
struct Cache {
var rowsHeight: CGFloat = 0
}
func makeCache(subviews: Subviews) -> Cache { Cache() }
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let resolved = proposal.replacingUnspecifiedDimensions()
let rowsHeight = subviews.first?.sizeThatFits(
ProposedViewSize(width: resolved.width, height: nil)
).height ?? 0
cache.rowsHeight = rowsHeight
// Fill the proposed (viewport) height when the rows are shorter; grow to
// the rows' natural height when they overflow it. The parent
// `.frame(minHeight:)` supplies the viewport floor.
return CGSize(width: resolved.width, height: max(rowsHeight, resolved.height))
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
guard let rows = subviews.first else { return }
let rowsHeight = cache.rowsHeight

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

rows.place(
at: CGPoint(x: bounds.minX, y: bounds.minY),
proposal: ProposedViewSize(width: bounds.width, height: rowsHeight)
)
guard subviews.count > 1 else { return }
// Size the empty area against the explicit viewport (or the rows' height
// when they overflow it), never against `bounds.height` — which could be
// the rows' natural height alone if the parent placed us at our content
// size.
let emptyHeight = SidebarWorkspaceScrollLayout.emptyAreaFillHeight(
viewportHeight: viewportHeight,
rowsHeight: rowsHeight
)
subviews[1].place(
at: CGPoint(x: bounds.minX, y: bounds.minY + rowsHeight),
proposal: ProposedViewSize(width: bounds.width, height: emptyHeight)
)
}
}
20 changes: 17 additions & 3 deletions Sources/SidebarWorkspaceRenderItem.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,31 @@
import Foundation

/// Stable, allocation-free identity for a `SidebarWorkspaceRenderItem`.
///
/// ForEach gathers row identifiers on every list diff, so the id must be cheap
/// to create and hash. The previous `String` form
/// (`"workspace.\(uuid.uuidString)"`) allocated and formatted a fresh string on
/// every getter call; with the sidebar re-diffing all rows per update it was the
/// hottest app-owned frame in the
/// https://github.com/manaflow-ai/cmux/issues/5764 livelock spindump. The case
/// keeps group headers and workspace rows from ever colliding on the same UUID.
enum SidebarWorkspaceRenderItemID: Hashable {
case group(UUID)
case workspace(UUID)
}

/// One drawable item in the workspace sidebar.
@MainActor
enum SidebarWorkspaceRenderItem {
case groupHeader(WorkspaceGroup, memberWorkspaceIds: [UUID])
case workspace(Workspace)

var id: String {
var id: SidebarWorkspaceRenderItemID {
switch self {
case .groupHeader(let group, _):
return "group.\(group.id.uuidString)"
return .group(group.id)
case .workspace(let workspace):
return "workspace.\(workspace.id.uuidString)"
return .workspace(workspace.id)
}
}

Expand Down
Loading
Loading