Skip to content

Remove sidebar row-ids preference aggregation feeding the layout livelock (#2586) - #5708

Merged
lawrencecchen merged 4 commits into
mainfrom
issue-2586-sidebar-rowids-livelock
Jun 9, 2026
Merged

lawrencecchen merged 4 commits into
mainfrom
issue-2586-sidebar-rowids-livelock

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Removes SidebarWorkspaceRowIdsPreferenceKey entirely: the per-row/per-group-header .preference emitters, the sidebar-wide .onPreferenceChange aggregation, and the laidOutWorkspaceRowIds @State.
  • Selected-workspace scroll-into-view now calls ScrollViewProxy.scrollTo unconditionally for pending requests. scrollTo resolves .id() values in lazy containers without the target row being realized, and unknown ids are a harmless no-op, so the "wait until the row is laid out" gate was unnecessary.

Why

This is the surviving cmux-owned edge in the #2586 sidebar layout livelock. Fresh evidence from today (0.64.14-nightly, macOS 26.5, 36 workspaces, no groups — well below the 100-workspace scale discussed earlier): scrolling the sidebar pegged the main thread at 100% inside a single GraphHost.flushTransactions() that never returned to the run loop. Two sample captures minutes apart show the identical signature:

  • ~99% of main-thread samples inside one NSHostingView.beginTransaction run-loop observer callback, looping runTransaction → LazySubviewPlacements.placeSubviews over the sidebar ForEach.
  • SidebarWorkspaceRowIdsPreferenceKey.reduce → Set.formUnion in the hot loop (the Fix sidebar workspace frame collection virtualization #5325-gated SidebarWorkspaceRowFramePreferenceKey is absent, consistent with vlechemin's 0.64.13 table).
  • The refill side of the loop is visible too: LazyLayoutCacheItem.AllItemsPhaseMutation → LazyLayoutViewCache.updateItemPhases → propagate_dirty, plus LazyLayoutViewCache.signalPrefetch → NSHostingView.requestUpdate queuing the next transaction.
  • Because the observer callback never returns, the autorelease pool never drains: RSS grew ~10 MB/s to 6.1 GB before the process was killed (heap: 4.3M NSCompositeAppearance, 8.6M NSMutableDictionary).

Mechanism: the preference value is the set of currently-realized lazy rows, which changes on essentially every layout pass while scrolling. Each change re-runs the N-row reduce and fires onPreferenceChange, which writes @State from inside the update cycle, scheduling another transaction inside the same flushTransactions loop. Once lazy phase churn keeps the realized-row set oscillating, the cycle stops converging and the main thread never escapes.

This is the same class-elimination #5325 applied to the sibling drop-target anchor preference: delete the steady-state per-row preference traffic instead of patching timing. With this PR the workspace sidebar publishes no per-row preferences at rest at all.

How this is principled vs hacky

Principled: it deletes the feedback mechanism rather than debouncing or gating it, and the replacement (scrollTo on lazy-container ids) is the API's intended use. Residual risk: if some macOS build fails to resolve an unrealized row id, scroll-into-view for far-off-screen rows would silently no-op — cmuxUITests/WorkspaceSidebarScrollUITests (Cmd+1 from the bottom of a 20-workspace overflow list) covers exactly that path on CI.

Testing

  • WorkspaceSidebarScrollUITests via test-e2e.yml (covers selected-row scroll-into-view incl. off-screen Cmd+1 and Move-to-Top): run pending below.
  • A deterministic automated repro of the livelock itself is not practical — the non-convergence is timing/AttributeGraph-state dependent and has resisted deterministic repro across every report in the issue thread. The meaningful verification is the existing scroll behavior suite plus dogfood under the reporter conditions (select tab + scroll).
  • Tagged build rowids for dogfood.

Issues


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
Changes lazy-sidebar scroll behavior (no longer gated on realized rows); collapsed-group targeting is new but model-driven, with UI tests covering off-screen scroll paths.

Overview
Removes the sidebar layout feedback loop that caused main-thread livelock (#2586) by deleting SidebarWorkspaceRowIdsPreferenceKey, per-row/group .preference emitters, the sidebar .onPreferenceChange handler, and @State laidOutWorkspaceRowIds.

Selected-workspace scroll-into-view no longer waits for lazy rows to report as laid out. flushPendingSelectedWorkspaceScroll always calls ScrollViewProxy.scrollTo on an id resolved from model data via SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId (selected workspace, or the group header anchor when the group is collapsed and the member row has no .id). Scroll helpers now take WorkspaceListRenderContext instead of raw id lists or preference-derived row sets.

Unit tests cover the new scroll-target mapping for ungrouped, expanded-group, and collapsed-group cases.

Reviewed by Cursor Bugbot for commit 5bab011. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Removes the sidebar row-id preference aggregation and scroll gate to break the #2586 layout livelock. Scrolling now always targets the selected workspace (or its group header when collapsed) via ScrollViewProxy.scrollTo.

  • Bug Fixes
    • Removed the row-id preference pipeline and state: SidebarWorkspaceRowIdsPreferenceKey, per-row/group .preferences, sidebar .onPreferenceChange, and laidOutWorkspaceRowIds.
    • Selected-workspace scroll now unconditionally calls scrollTo with a policy-resolved id; lazy id resolution handles unrealized rows and unknown ids as a no-op. Collapsed-group selections map to the group header via SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId; tests cover no-group/expanded/collapsed cases.
    • Imported Foundation in SidebarState for UUID.

Written for commit 5bab011. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Refactor
    • Improved workspace sidebar scrolling: selection now scrolls immediately and more reliably, with more consistent handling when navigating into collapsed groups so the correct header is brought into view.
  • Tests
    • Added unit tests covering scroll behavior for open/closed group scenarios to ensure consistent navigation.

…elock

The workspace sidebar published SidebarWorkspaceRowIdsPreferenceKey from
every row and group header, reduced it sidebar-wide on every layout pass,
and wrote the aggregated set into @State from onPreferenceChange to gate
selected-workspace scroll-into-view on "row is laid out". During lazy
layout churn (scroll, tab switch) that preference value changes on every
pass, so each pass scheduled another transaction from inside
GraphHost.flushTransactions; once the cycle stopped converging the main
thread never returned to the run loop (100% CPU, unbounded autorelease
growth, CLI dispatch_sync deadlock).

ScrollViewProxy.scrollTo resolves .id() values in lazy containers without
the row being realized, and unknown ids are a no-op, so the laid-out
gate is unnecessary: scroll pending requests unconditionally and delete
the preference key, the per-row emitters, the sidebar-wide
onPreferenceChange, and the laidOutWorkspaceRowIds state.

Same class-elimination as #5325 did for the sibling
SidebarWorkspaceRowFramePreferenceKey (drop-target anchors), which no
longer appears in hang samples since it shipped.

Issue: #2586

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 9, 2026 6:33pm
cmux-staging Building Building Preview, Comment Jun 9, 2026 6:33pm

@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR removes PreferenceKey-based layout tracking for sidebar workspace rows and instead tracks a single pendingSelectedWorkspaceScrollId; scrolling now occurs immediately and unconditionally via ScrollViewProxy, with collapsed-group redirects handled by a computed scroll target.

Changes

Workspace scrolling refactoring

Layer / File(s) Summary
Scroll state and logic refactoring
Sources/ContentView.swift
pendingSelectedWorkspaceScrollId replaces laid-out-ID tracking; request/flush logic updated to accept WorkspaceListRenderContext and call flushPendingSelectedWorkspaceScroll(proxy, renderContext:), scrolling unconditionally to the computed target.
Event wiring for selection and workspace list
Sources/ContentView.swift
Selection changes and renderContext.workspaceIds changes call requestSelectedWorkspaceScroll(...); when requests are suppressed the pending scroll is flushed using the provided renderContext rather than waiting for preference updates.
Preference key infrastructure removal
Sources/ContentView.swift, Sources/VerticalTabsSidebar+WorkspaceGroups.swift
Removed .onPreferenceChange(SidebarWorkspaceRowIdsPreferenceKey.self), per-row .preference(...) emissions, the SidebarWorkspaceRowIdsPreferenceKey type, and the group header .preference(...) modifier; sidebarWorkspaceFrameAnchor usage remains.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ContentView
  participant ScrollViewProxy
  participant GroupHeaderAnchor

  User->>ContentView: change selected workspace
  ContentView->>ContentView: set pendingSelectedWorkspaceScrollId
  ContentView->>ScrollViewProxy: flushPendingSelectedWorkspaceScroll(scrollTarget)
  alt workspace belongs to collapsed group
    ScrollViewProxy->>GroupHeaderAnchor: scrollTo(groupAnchorId)
  else normal workspace
    ScrollViewProxy->>ScrollViewProxy: scrollTo(workspaceId)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • Ari4ka

Poem

A rabbit nudges the sidebar bright,
One pending ID, it hops to light.
No scattered prefs to track the trail,
A tidy scroll, a simple tale. 🐰✨


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error PR introduces Task.sleep (150ms), DispatchQueue.asyncAfter delays, and NSLock in production code—all prohibited by swift-blocking-runtime.md rules. Replace Task.sleep with proper signal/callback, DispatchQueue.asyncAfter with state transitions, and NSLock with MainActor-isolated state.
Cmux Algorithmic Complexity ❌ Error requestSelectedWorkspaceScroll() uses O(n) Array.contains() on workspaceIds (n~1000) per tab selection event instead of O(1) tabIndexById[id] != nil. Replace renderContext.workspaceIds.contains(selectedWorkspaceId) with renderContext.tabIndexById[selectedWorkspaceId] != nil for O(1) hot-path lookup.
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: removing the sidebar row-ids preference aggregation that caused the layout livelock.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New pure helper function with Sendable parameters (UUID, WorkspaceGroup?); no implicit MainActor issues, background context access, or mutable reference types without isolation.
Cmux Expensive Synchronous Load ✅ Passed PR refactors scrolling with lightweight UI operations. No expensive loaders or disk I/O added to interactive paths per swift-expensive-sync-load.md rule.
Cmux Cache Substitution Correctness ✅ Passed Scroll operations use fresh renderContext data from TabManager's @Published properties built each render, applied to transient UI hints only, not persisted values.
Cmux No Hacky Sleeps ✅ Passed All PR changes are Swift files; check applies only to TypeScript, JavaScript, shell, and build/runtime scripts. Swift timing covered by separate check.
Cmux Swift Concurrency ✅ Passed PR removes preference aggregation feedback loop causing livelock; introduces no new Dispatch queues, Combine app state, completion handlers, or Tasks.
Cmux Swift @Concurrent ✅ Passed PR adds only synchronous, UI-bound Swift code: scrollTargetWorkspaceId helper and scroll-management functions in ContentView. No async, @concurrent, or nonisolated async work.
Cmux Swift File And Package Boundaries ✅ Passed PR touches existing files only: ContentView -1 net line, SidebarState +14 to 105-line file, no new oversized files, no mixed responsibilities, proper unit test coverage.
Cmux Swift Logging ✅ Passed PR adds one NSLog call in Sources/ContentView.swift guarded by #if DEBUG, which is an explicitly allowed case per swift-logging.md. No other logging violations in the modified files.
Cmux User-Facing Error Privacy ✅ Passed PR contains no new user-facing error messages, alerts, or sensitive information; changes are internal implementation refactoring with developer-only comments.
Cmux Full Internationalization ✅ Passed PR contains no new user-facing text or localization changes; only internal refactoring of scrolling logic and test code.
Cmux Swiftui State Layout ✅ Passed PR removes preference key aggregation causing livelock, adds @State only in main sidebar container with event-handler mutations; no new @Published, render mutations, or row-level store refs.
Cmux Architecture Rethink ✅ Passed Removes problematic preference observer pattern causing livelock. Replaces with single @State and direct scroll calls. No timing repairs, polling, locks, or split lifecycle ownership introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No NSWindow, NSPanel, NSWindowController, or SwiftUI Window code added. PR modifies sidebar scrolling logic only (preference-key removal, @State approach, helper function). Check does not apply.
Cmux Source Artifacts ✅ Passed All changed files are hand-written source code and unit tests in legitimate directories (Sources/, cmuxTests/). No artifact patterns, generated code markers, or prohibited directories detected.
Description check ✅ Passed The PR description comprehensively covers the summary (what/why), testing approach, and context with related issue links, but lacks a demo video and incomplete checklist items.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-2586-sidebar-rowids-livelock

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 830838bd69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/ContentView.swift Outdated
Comment on lines 10891 to 10892
proxy.scrollTo(selectedWorkspaceId)
pendingSelectedWorkspaceScrollId = nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep pending scrolls for hidden group children

When the selected workspace is a non-anchor child of a collapsed group (for example after the non-focus socket/CLI collapse path, which intentionally does not change selection), renderContext.workspaceIds still contains that UUID even though SidebarWorkspaceRenderItem.renderItems does not render a view with that .id. scrollTo is therefore an unknown-id no-op, but this now clears pendingSelectedWorkspaceScrollId; expanding the group later does not change workspaceIds or selectedTabId, so the selected row is never scrolled into view. Previously the pending request survived until the row preference appeared after expansion.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 68adaa7: hidden members of a collapsed group now resolve their scroll target to the group header (anchor workspace id) from render-context data, so the scroll lands where the workspace lives instead of silently no-oping. Deliberately not restoring the deferred-scroll-on-expand: that required tracking what the lazy layout realized, which is the feedback edge this PR removes.

@greptile-apps

greptile-apps Bot commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR removes SidebarWorkspaceRowIdsPreferenceKey entirely to eliminate the feedback loop that caused the sidebar layout livelock (#2586): per-row .preference emitters → sidebar-wide onPreferenceChange → @State write during layout → another transaction → repeat. Scroll-into-view now calls ScrollViewProxy.scrollTo unconditionally, resolving IDs in lazy containers without requiring rows to be realized, with unknown IDs harmlessly no-oping.

  • Deleted: SidebarWorkspaceRowIdsPreferenceKey, per-row and group-header .preference(key:value:) calls, laidOutWorkspaceRowIds @State, and the onPreferenceChange scroll-flush gate in VerticalTabsSidebar.
  • Added: SidebarSelectedWorkspaceScrollPolicy.scrollTargetWorkspaceId — a pure, model-based function that redirects a collapsed-group selection to the group header's anchorWorkspaceId; all three cases are covered by new unit tests.
  • Flush paths: requestSelectedWorkspaceScroll now flushes immediately; the workspace-order-change notification path still relies on onChange(of: renderContext.workspaceIds) as its implicit flush trigger (noted in a prior review comment as a documentation gap, unchanged by this PR).

Confidence Score: 5/5

Safe to merge: the change deletes a well-identified feedback loop rather than patching it, the replacement API is the platform's intended mechanism, and the collapsed-group scroll target is decided from model data that is always present.

The deletion of SidebarWorkspaceRowIdsPreferenceKey and its surrounding @State write removes the exact mechanism driving the livelock, and the new unconditional scrollTo path is simpler with no new state or observers introduced. scrollTargetWorkspaceId is pure and deterministic; the three unit tests cover the behaviorally distinct cases. The only pre-existing gap — the implicit coupling between the workspace-order-change notification path and onChange(workspaceIds) as its flush trigger — was already noted in a prior review and is unchanged by this PR.

No files require special attention; all changes are deletions or small, self-contained additions.

Important Files Changed

Filename Overview
Sources/ContentView.swift Removes laidOutWorkspaceRowIds state, SidebarWorkspaceRowIdsPreferenceKey struct, all .preference emitters, and the onPreferenceChange flush gate; scroll helpers now accept WorkspaceListRenderContext and flush unconditionally. Logic is correct and flush paths remain intact.
Sources/Sidebar/SidebarState.swift Adds scrollTargetWorkspaceId to SidebarSelectedWorkspaceScrollPolicy — a pure, model-driven function that maps a collapsed-group workspace selection to the group header's anchor ID. Implementation is clean and well-documented.
Sources/VerticalTabsSidebar+WorkspaceGroups.swift Removes the single .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([group.anchorWorkspaceId])) call from the group header view — a clean deletion with no other changes.
cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift Adds three unit tests covering no-group, expanded-group, and collapsed-group cases for scrollTargetWorkspaceId; uses a makeGroup factory helper. Tests are correct and cover the behaviorally important collapsed case.

Reviews (3): Last reviewed commit: "Import Foundation in SidebarState for UU..." | Re-trigger Greptile

…sed group

Codex review: a member of a collapsed group renders no row, so scrollTo
with its UUID is a no-op, and clearing the pending request dropped the
old deferred-scroll-on-expand behavior. Resolve the scroll target from
render-context data instead: hidden members target their group header
(anchor workspace id), which is always present. Never decided from what
the lazy layout realized, so it cannot re-enter the preference/layout
feedback cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lawrencecchen and others added 2 commits June 9, 2026 11:28
…lPolicy

Keeps ContentView.swift under the Swift file length budget
(workflow-guard-tests) and makes the collapsed-group fallback unit
testable. Adds coverage: no group / expanded group target the workspace
itself, collapsed group targets the group anchor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lawrencecchen
lawrencecchen merged commit eaf6ee3 into main Jun 9, 2026
29 of 31 checks passed
@lawrencecchen
lawrencecchen deleted the issue-2586-sidebar-rowids-livelock branch June 9, 2026 19:04
lawrencecchen added a commit that referenced this pull request Jun 9, 2026
The repo-loss snapshot commit flattened a pre-#5708 working tree on top
of origin/main, accidentally reverting the merged sidebar row-ids
preference removal (#5708).
These four files are unrelated to the QR work; restore them to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
austinywang added a commit that referenced this pull request Jun 9, 2026
#5708 removed SidebarWorkspaceRowIdsPreferenceKey, its per-row
.preference emitters, the sidebar-wide onPreferenceChange aggregation,
and laidOutWorkspaceRowIds — the cmux-owned edge of the #2586 sidebar
layout livelock — and made selected-workspace scroll-into-view call
scrollTo unconditionally.

This branch's overflow fix had layered a row-layout *completeness* gate
on top of that same removed per-row IDs aggregation. Reconcile by
dropping the completeness machinery entirely (it depended on the
deleted key) and keeping only:

- a single whole-content GeometryReader that measures the rows
  container height once (not a per-row preference reduce), deduped by
  SidebarWorkspaceRowsMeasurement.isEquivalent so sub-pixel jitter from
  agent-driven row re-renders never writes @State — so it cannot
  re-feed the #2586 transaction cycle; and
- the finite empty-area height that fixes the original #3241 phantom
  scrollbar.

Also simplify SidebarWorkspaceScrollLayout (drop rowsOverflow,
rowsLayoutCompleteness, and the completeness enum), restore the ghostty
pointer to origin/main, and update the layout tests for the simplified
API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lawrencecchen added a commit that referenced this pull request Jun 10, 2026
…n, ECC M to L (#5727)

* WIP snapshot: working tree preserved after repo/.git loss (2026-06-09 incident)

* Restore sidebar scroll files to origin/main

The repo-loss snapshot commit flattened a pre-#5708 working tree on top
of origin/main, accidentally reverting the merged sidebar row-ids
preference removal (#5708).
These four files are unrelated to the QR work; restore them to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restructure compact QR coding per cmux file-organization policy

Replace the CmxAttachTicketCompactCoding namespace enum with an
injectable CmxAttachTicketCompactCoder struct, move each compact DTO
(CompactAttachTicket, CompactAttachRoute, CompactAttachEndpoint) into
its own file, and turn the pure private static helpers into file-scope
private functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope compact-attach helpers onto their owning types per package conventions

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Jun 13, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
#5708,
#5846,
#5855, and
#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
#5325 (frame anchors) and
#5708 (row IDs) removed their siblings.

Same approach as #5852, re-ported on
top of the #5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes #5764.
Helps #2586,
#5570,
#5845.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for #5764,
#5845,
#2586; complements the structural
loop fix in #6019.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (#5764,
#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ShubhamPatilsd pushed a commit to emergent-inc/mosaic that referenced this pull request Jul 9, 2026
…n, ECC M to L (#5727)

* WIP snapshot: working tree preserved after repo/.git loss (2026-06-09 incident)

* Restore sidebar scroll files to origin/main

The repo-loss snapshot commit flattened a pre-#5708 working tree on top
of origin/main, accidentally reverting the merged sidebar row-ids
preference removal (manaflow-ai/cmux#5708).
These four files are unrelated to the QR work; restore them to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restructure compact QR coding per cmux file-organization policy

Replace the CmxAttachTicketCompactCoding namespace enum with an
injectable CmxAttachTicketCompactCoder struct, move each compact DTO
(CompactAttachTicket, CompactAttachRoute, CompactAttachEndpoint) into
its own file, and turn the pure private static helpers into file-scope
private functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope compact-attach helpers onto their owning types per package conventions

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ShubhamPatilsd pushed a commit to emergent-inc/mosaic that referenced this pull request Jul 9, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
manaflow-ai/cmux#5708,
manaflow-ai/cmux#5846,
manaflow-ai/cmux#5855, and
manaflow-ai/cmux#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
manaflow-ai/cmux#5325 (frame anchors) and
manaflow-ai/cmux#5708 (row IDs) removed their siblings.

Same approach as manaflow-ai/cmux#5852, re-ported on
top of the manaflow-ai/cmux#5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes manaflow-ai/cmux#5764.
Helps manaflow-ai/cmux#2586,
manaflow-ai/cmux#5570,
manaflow-ai/cmux#5845.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
manaflow-ai/cmux#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845,
manaflow-ai/cmux#2586; complements the structural
loop fix in manaflow-ai/cmux#6019.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 5bab011a Deployed Jun 9, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant