Skip to content

fix(sidebar): soften tab reorder animation - #17139

Merged
austinywang merged 66 commits into
mainfrom
fix/tab-reorder-animation
Oct 5, 2026
Merged

austinywang merged 66 commits into
mainfrom
fix/tab-reorder-animation

Conversation

@austinywang

@austinywang austinywang commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

This follow-up carries the sidebar polish and drag behavior fixes for #17074.

Changes:

  • Slow right-sidebar tab reorder motion to a responsive 0.34s damped spring.
  • Preserve the empty Cloud state labels and expanded caret from the parent PR.
  • Fix the regression where dragging a right-sidebar tab left into a main pane stayed in the reorder gesture and showed an unavailable-drop X. The handoff now recognizes leaving the bar horizontally as well as vertically.

Validation: git diff --check; Swift syntax check for changed Swift sources. The exact-head dev build is still being rebuilt after the drag handoff fix.

Summary by CodeRabbit

  • New Features
    • Added refresh controls to Cloud Machines and Devices section headers, with progress indicators while refreshing.
    • When machine creation reaches the plan limit, an Upgrade Plan action is available when a higher plan may be available.
    • Sidebar mode labels adapt to available space with a fading edge, and mode-bar sizing better preserves tab names.
  • Bug Fixes
    • Empty cloud machine lists show “No cloud machines yet,” and device discovery and empty states consistently refer to “devices.”
    • Ports status rows no longer trigger actions when clicked.
    • Link-dependent tabs are hidden while a machine is connecting.
    • Sidebar width adjusts to preserve the minimum space required by its controls.

lucasr1b and others added 30 commits October 3, 2026 09:45
…ecting row up with new workspace, hover on the ports action

- while a machine connects, ports, terminals and displays have nothing current, so the tab row keeps only resources (fleet telemetry)
- the connecting spinner takes new workspace's chevron column
- the ports status action (refresh, set up vpn) is a quiet rounded chip with the tree's fills; its title brightens and fill deepens on hover and press
the live cloud tab lists machines under the cloud machines section, and
dropping one there is refused today. this fails until the fix.
…he section headers

machine drags under the cloud machines section were refused because the
drop only looked at top-level rows; drops and menu moves now find the
machines wherever they sit.

the real machine row is the drag visual: open machines close for the
drag, peers spring aside against frozen geometry, and the drop lands
every row from where it is on screen. mode tabs drag the same way and
hand off to a native drag when pulled out of the bar, so they still open
as panes. switching tabs slides one selection highlight, and an icon-only
tab centers its icon. refresh moves from the bottom of the panel to the
cloud machines and my devices headers.
…up pr

keeps this pr to machine and tab reorder. the refresh icons in the
cloud machines and my devices headers, the sliding tab highlight and the
centered tab icons come back in the stacked cloud sidebar polish pr.
…light, centered tab icons

refresh moves from the bottom of the cloud panel to an icon left of the
cloud machines + and the my devices menu, with the same hover and a
spinner that stays on screen while it runs. switching tabs slides one
selection highlight while the widths re-share, and an icon-only tab
centers its icon.
review findings on the lift:
- closing open machines above the source moved its row up, and that
  shift counted as pointer travel, so a small nudge could move the
  machine several slots. only the pointer's travel picks the slot now;
  the held row glides from under the hand into its closed slot
- a drag whose end is never reported cancels once the button has been
  up for a moment, instead of following the pointer until the next click
- the release lays out before landing, so the rows glide instead of snapping
- mode bar: a late gesture change after the release no longer starts a
  phantom drag, and tab frames are measured without the drag offset
- tests: the lifted drop gets a proposal that disagrees with the lift, and
  a new test opens machines above the held one
…l tab names, smoother switching and resize, upgrade at the limit

- ports tab: the status text and its button start on the ports tab's edge
- an empty fleet keeps a "No cloud machines yet" line, so cloud machines
  keeps its chevron; my devices says "No other devices yet"
- the open-as-pane button stays in the bar in every tab; a tab that can't
  open as a pane says so in its tooltip
- the right sidebar's minimum (and opening) width follows the mode tabs
  shown, so every tab name fits with the trailing buttons
- switching tabs moves one highlight with no crossfade, and row
  selection is a layer so it no longer flickers while the sidebar resizes
- a failed create keeps its warning icon on the name's line
- a create refused at the plan's machine limit offers Upgrade Plan when a
  bigger plan exists
- the cloud machines refresh icon spins only for a refresh someone asked for
…cons; ports layout back as it was

the minimum and opening width now leave room for one full tab name (the
widest, so it doesn't change as the selection moves) with every other
tab at its icon, instead of every name in full. the ports tab's status
alignment goes back to main's.
…reate keeps its status readable

the cloud machines and my devices refresh icons move from the header's
trailing buttons to right after the title and count, small and in the
count's grey. the header passes clicks through to the row everywhere but
the icon, so clicking the header still opens and closes the section.

in a narrow sidebar a failed create's name gives way before its status.
…er other devices"

- the section headers' refresh icons take the same hover fill as + and
  ⋯; their resting color and size are unchanged
- switching tabs no longer moves a highlight across the bar. the widths
  ease on one short curve with no overshoot, the old tab's fill fades as
  it narrows and the new one's as it opens, labels are uncovered by their
  slot (with a soft edge) instead of re-truncating every frame, and an
  icon-only tab's icon glides to its leading spot
- my devices and settings say "Discover other devices" (and "Stop
  discovering other devices"), in all 9 locales
…olish

brings the ports status fix (only its button acts) and its hover chip
into this build. connectingLeading moves to the machine detail tabs
extension with the helpers #17070 moved there.
the row drops the "New Machine" name once the create has failed and
shows only the warning and "Couldn't create machine"; the name and the
reason stay in its tooltip and on the create's page.
…toggling the section

each reload of a section header cleared the icon's clickable spot, and
SwiftUI reports the icon's frame only when it moves, so after the first
reload the click fell through to the row and opened or closed the
section. a header now keeps the spot across reloads; it clears only when
the cell is reused for another kind of row.
takes #17070's review fixes and its main merge. conflicts: ports status
keeps the chip title and hides an empty message; node actions keep both
newWorkspaceOnResolvedMachine and the section refreshes; the lift card
fill keeps the hover tint on the struct style; the panel keeps the header
refresh with main's reveal; the project file is #17070's with this
branch's files wired and the deleted refresh button removed.
both header icons use the panel's one refresh (fleet and devices) instead
of two new single-section actions; each icon still spins only for its own
section. shorter ports status comment and denser panel arguments.
Merge-main commit by scripts/merge-main.sh.
Merged by scripts/merge-main.sh: origin/main at db43cdb, the newest commit with green CI fast guards (1 newer skipped).

Merge-main-previous-head: 9cb17b6
Merge-main-base: db43cdb
Merge-main commit by scripts/merge-main.sh.
Merged by scripts/merge-main.sh: origin/main at d2adb1b.

Merge-main-previous-head: ddb9c92
Merge-main-base: d2adb1b
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

CI fast guards passes on 5719aeb04e (https://github.com/manaflow-ai/cmux/actions/runs/37266650827).

austinywang added a commit that referenced this pull request Oct 4, 2026
)

* cloud sidebar: workspaces reorder with the same lift as machines

the real workspace row follows the pointer and its pin-tier siblings slide aside, open
workspaces close for the drag and reopen after, and the drop commits the slot the
rows show. leaving the tree puts the rows back and gives the drag its image again,
so a workspace still drops onto a pane.

* cloud sidebar: workspace lift review fixes

a lifted drop skips the pane ownership check (it only reorders), the drag image is
only blanked once the row lifts and comes back if the tree reloads mid-drag, and the
snapshot keeps its size.

* cloud sidebar: lift begin returns whether the row lifted

* cloud sidebar: workspace lift finds rows by id and snapshots the row itself

the lift matched rows by object identity, and the tree can hold different objects for
the same workspace, so the drag never lifted. the hand-off picture now comes from the
row view instead of the drag frame. debug logs say why a lift was skipped.

* Animate Cloud machine detail tab switching

* Fix tab strip animation composition

* Fix cloud reorder drag origin after collapsing rows

* Restore outline drag origin capture

* Revert "Fix cloud reorder drag origin after collapsing rows"

This reverts commit 59a8d91.

* Keep dragged cloud folder open during lift

* Test that a lifted Cloud row stays under the hand when rows close above it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Anchor the Cloud reorder lift on the held row

Closing open rows for a machine or workspace drag moved the held row's
slot up by the height that closed above it, and the lift glided the row
into that slot, leaving it that far above the pointer. The bottom row
drifted most. The lift now scrolls the list so the slot stays where the
row stood, carries any remainder as one shift on every row, and anchors
the reopen at the end on the landed row, so the row stays under the hand
and a cancel restores the original scroll. The held row's own folder
closes again so every peer is one row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Import CmuxSidebar in the right sidebar tab tests

RightSidebarModeBarDragLayout moved into CmuxSidebar in #17070, so the
cmuxTests target stopped compiling. Same line as PR #17139.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test that a lift keeps every row reachable and returns its scroll range

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Borrow scroll range for the lift instead of shifting every row

A fixed shift on every row left rows at one end of a long list out of
reach and showed blank bands while autoscrolling, since rows without
views never draw. The container now lends the scroll view extra inset
range for the drag, the real scroll does all of the anchoring, and the
range goes back at the drop, cancel, or discard. Hover stays on the held
row while begin lays out, and a list with nothing to scroll rests at its
top after a drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test that a lift restores the scroll view's own insets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the scroll view's insets still while a lift borrows range

The Cloud tree's scroll view adjusts its insets automatically, so AppKit
recomputed them on its next layout and dropped the borrowed range
mid-drag. The container now lends on top of the insets in effect, turns
automatic adjustment off for the loan, and restores both when the range
comes back. Resting insets are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep throwing lookups out of #expect in the async lift test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the held row's slot, not a timed glide, in the lift tests

The determinism guard rejects sleep-then-assert. The drift the sleep
waited out was the row gliding into a slot above the hand, so the tests
now check that the slot itself stays where the hand pressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Scroll the lift test fixture to a finite end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the hand to a window point in the lifted cancel test

Rows closing above the held machine now scroll the outline so its slot
stays under the hand, so an unmoved hand is a window point, not the
press's document coordinate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Austin Wang <38676809+austinywang@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 4, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@cursor

cursor Bot commented Oct 4, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

austinywang added a commit that referenced this pull request Oct 5, 2026
…17222)

* cloud sidebar: workspaces reorder with the same lift as machines

the real workspace row follows the pointer and its pin-tier siblings slide aside, open
workspaces close for the drag and reopen after, and the drop commits the slot the
rows show. leaving the tree puts the rows back and gives the drag its image again,
so a workspace still drops onto a pane.

* cloud sidebar: workspace lift review fixes

a lifted drop skips the pane ownership check (it only reorders), the drag image is
only blanked once the row lifts and comes back if the tree reloads mid-drag, and the
snapshot keeps its size.

* cloud sidebar: lift begin returns whether the row lifted

* cloud sidebar: workspace lift finds rows by id and snapshots the row itself

the lift matched rows by object identity, and the tree can hold different objects for
the same workspace, so the drag never lifted. the hand-off picture now comes from the
row view instead of the drag frame. debug logs say why a lift was skipped.

* Animate Cloud machine detail tab switching

* Fix tab strip animation composition

* Fix cloud reorder drag origin after collapsing rows

* Restore outline drag origin capture

* Revert "Fix cloud reorder drag origin after collapsing rows"

This reverts commit 59a8d91.

* Keep dragged cloud folder open during lift

* Test that a lifted Cloud row stays under the hand when rows close above it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Anchor the Cloud reorder lift on the held row

Closing open rows for a machine or workspace drag moved the held row's
slot up by the height that closed above it, and the lift glided the row
into that slot, leaving it that far above the pointer. The bottom row
drifted most. The lift now scrolls the list so the slot stays where the
row stood, carries any remainder as one shift on every row, and anchors
the reopen at the end on the landed row, so the row stays under the hand
and a cancel restores the original scroll. The held row's own folder
closes again so every peer is one row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Import CmuxSidebar in the right sidebar tab tests

RightSidebarModeBarDragLayout moved into CmuxSidebar in #17070, so the
cmuxTests target stopped compiling. Same line as PR #17139.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test that a lift keeps every row reachable and returns its scroll range

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Borrow scroll range for the lift instead of shifting every row

A fixed shift on every row left rows at one end of a long list out of
reach and showed blank bands while autoscrolling, since rows without
views never draw. The container now lends the scroll view extra inset
range for the drag, the real scroll does all of the anchoring, and the
range goes back at the drop, cancel, or discard. Hover stays on the held
row while begin lays out, and a list with nothing to scroll rests at its
top after a drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test that a lift restores the scroll view's own insets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the scroll view's insets still while a lift borrows range

The Cloud tree's scroll view adjusts its insets automatically, so AppKit
recomputed them on its next layout and dropped the borrowed range
mid-drag. The container now lends on top of the insets in effect, turns
automatic adjustment off for the loan, and restores both when the range
comes back. Resting insets are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep throwing lookups out of #expect in the async lift test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assert the held row's slot, not a timed glide, in the lift tests

The determinism guard rejects sleep-then-assert. The drift the sleep
waited out was the row gliding into a slot above the hand, so the tests
now check that the slot itself stays where the hand pressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Scroll the lift test fixture to a finite end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pin the hand to a window point in the lifted cancel test

Rows closing above the held machine now scroll the outline so its slot
stays under the hand, so an unmoved hand is a window point, not the
press's document coordinate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cloud sidebar: test that a lift keeps open machines open and the bottom row under the hand

* cloud sidebar: keep open machines and workspaces open while dragging

closing them for the drag pulled the held row away from the cursor near the
bottom of the list. now each one moves with its rows. the closing path stays
behind machineLiftClosesOpenRows for its tests.

* Document shared Cloud plan loading strings

* Clear Swift warning budget regressions

* Return resolved Cloud workspace label

* Keep one Cloud plan localization record

* cloud sidebar: keep lift rows open

* cloud: keep prominent cloud buttons visible in an inactive window, keep a known plan through failed or cancelled checks, recheck settings on account change

* cloud: ignore stale plan refresh results

* cloud: label lift collapse closures and test workspace lifts

---------

Co-authored-by: Austin Wang <38676809+austinywang@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@cursor

cursor Bot commented Oct 5, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@cursor

cursor Bot commented Oct 5, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@austinywang
austinywang merged commit 8510b8d into main Oct 5, 2026
66 of 67 checks passed
@austinywang
austinywang deleted the fix/tab-reorder-animation branch October 5, 2026 05:28
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Merge receipt for 5719aeb04e, merged 2026-10-05 05:28:07 UTC

  • Not verified at merge: app-host unit tests (in progress), ci-status (not reported)
  • Verified: backend migrations applied, macOS compile admission, CI fast guards, detect-ios-changes, Fast static checks, GhosttyKit release check, guards (18), ios-tests, late-placement, linux-preflight, macOS admission gate, package-conventions-lint, and 5 more
  • Skipped by policy: admission-placement, apply-production, apply-staging, browser, Claude wrapper regressions, CLI product tests, Dogfood build #​${{ github.event.pull_request.number }}, full-suite-coverage, ios-simulator, ios-simulator-build, mobile-core-package, release-admission, and 9 more
  • Full suite: runs on main after merge.

Labeled merged-unverified: if main breaks near this merge, look here first.

@github-actions github-actions Bot added the merged-unverified A judging check was not green at merge; see the merge receipt comment label Oct 5, 2026
rustybret pushed a commit to rustybret/bmux that referenced this pull request Oct 5, 2026
15f2c03 Open New Machine immediately with cached plan data (manaflow-ai#17345)
2995bcc Wait for plan preload before showing new machine error (manaflow-ai#17329)
8510b8d fix(sidebar): soften tab reorder animation (manaflow-ai#17139)
austinywang added a commit that referenced this pull request Oct 5, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
austinywang added a commit that referenced this pull request Oct 5, 2026
* test(cloud): display health checks must not black-list websockify viewers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): stop Xvnc black-listing every viewer of additional displays

Xvnc black-lists a peer after five unauthenticated connections, for a
timeout that doubles each time. Every viewer of an additional display is
websockify on 127.0.0.1, and the helper's 30s health check read the RFB
greeting and hung up, so displays soon answered every connection with
"Too many security failures". Restored panes and quiet retries then
failed for minutes.

Start additional displays with -UseBlacklist=0 (SecurityTypes None behind
the private network gives it nothing to protect; Xvnc refuses runtime
changes), complete the None handshake in the health check so it clears
marks instead of adding them, and replace recovery's pgrep patterns, which
pgrep's regex dialect rejected, with exact argv matching.

Each helper request also runs in a plain shell now: the login shell cost
about 0.7s per call and only the long-lived service needs its environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): New Display hands over a running standby display

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(cloud): keep one standby display running per desktop VM

Each New Display started Xvnc, a desktop session and websockify on the
guest (about 0.7s) while the user waited. The guest helper now keeps one
display running outside the catalog, hands it over on create (about 0.1s)
and starts the next in the background. The standby is invisible to list,
does not count against capacity, and a restarted helper adopts it instead
of leaking it. Opening a machine's Displays tab, or restoring it selected,
runs guest discovery once so the standby is warm before the first click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): failed or launching standby displays are never misassigned

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): serialize standby handover; retire failed standbys; rediscover after wake

Concurrent creates could record a launching standby's number as a normal
display; take the standby under one lock and reserve running numbers. A
standby whose start failed is stopped and replaced instead of handed over
or retried forever. Displays-tab discovery runs once per open and retries
when a sleeping machine wakes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): align two sidebar tests with merged behavior

A connecting machine keeps its Terminals tab (#17326), and every visible
machine row requests a cached port scan once (#17074). Both tests still
asserted the earlier behavior and failed on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): standby never takes a recorded number; failed discovery retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): address review: standby race and Displays discovery retry

ensure_standby's locked check now also rejects a number a concurrent
create recorded after the unlocked probe, so a display can never be both
a catalog display and the standby.

Displays-tab discovery reports completion; a failed discovery clears the
request and retries, bounded to three attempts per shown machine, instead
of treating task launch as done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): connecting machines keep only the Resources tab (#17139)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a stale Displays discovery cannot retry after the tab reopens

Drive discovery completions explicitly instead of yielding a fixed number of times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): bind each Displays discovery completion to its request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
austinywang added a commit that referenced this pull request Oct 5, 2026
…#17327)

* test(cloud): display health checks must not black-list websockify viewers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): stop Xvnc black-listing every viewer of additional displays

Xvnc black-lists a peer after five unauthenticated connections, for a
timeout that doubles each time. Every viewer of an additional display is
websockify on 127.0.0.1, and the helper's 30s health check read the RFB
greeting and hung up, so displays soon answered every connection with
"Too many security failures". Restored panes and quiet retries then
failed for minutes.

Start additional displays with -UseBlacklist=0 (SecurityTypes None behind
the private network gives it nothing to protect; Xvnc refuses runtime
changes), complete the None handshake in the health check so it clears
marks instead of adding them, and replace recovery's pgrep patterns, which
pgrep's regex dialect rejected, with exact argv matching.

Each helper request also runs in a plain shell now: the login shell cost
about 0.7s per call and only the long-lived service needs its environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): New Display hands over a running standby display

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(cloud): keep one standby display running per desktop VM

Each New Display started Xvnc, a desktop session and websockify on the
guest (about 0.7s) while the user waited. The guest helper now keeps one
display running outside the catalog, hands it over on create (about 0.1s)
and starts the next in the background. The standby is invisible to list,
does not count against capacity, and a restarted helper adopts it instead
of leaking it. Opening a machine's Displays tab, or restoring it selected,
runs guest discovery once so the standby is warm before the first click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): failed or launching standby displays are never misassigned

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): serialize standby handover; retire failed standbys; rediscover after wake

Concurrent creates could record a launching standby's number as a normal
display; take the standby under one lock and reserve running numbers. A
standby whose start failed is stopped and replaced instead of handed over
or retried forever. Displays-tab discovery runs once per open and retries
when a sleeping machine wakes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a closed display view is not rebuilt from a stale graph

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): load every workspace display and keep closed displays closed

Displays beyond the first in a Cloud workspace layout appeared late: the
catalog only learns display:N from guest discovery and drops memberships
for displays it does not know, and nothing on open or restore ran
discovery. Publishing a graph whose memberships name unknown displays now
runs discovery once per lifecycle generation.

Closed display panes came back: the membership removal is queued while
reconciliation still reads the graph holding the token, so it rebuilt the
pane under a new panel id and left an orphaned token that resurrected it
after every close. Closing now fences the view until a fetched graph drops
its token, retries a removal that has not landed, and a pane rebuilt from
an orphaned token removes that token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): discover member displays on deltas; bound and scope token cleanup

Membership rows arrive as projection deltas, so discovery also runs from
publishDelta. Orphan cleanup skips other Macs' tokens (a no-op write that
still cost a guest snapshot), removal retries stop after three attempts,
and the client id is read once per close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): prune this Mac's orphaned display memberships in open workspaces

A membership token whose pane is gone (an old close whose removal never
landed, a crash, a pane rebuilt under a new id) showed as a duplicate
display row and resurrected the display after it was closed; the
reconciler treats one live pane as satisfying every token for that
display, so nothing removed it. After reconciling an open Cloud
workspace, remove this Mac's tokens with no live display pane, once the
machine's restore has settled. Tokens of workspaces not open here, and of
other Macs, are kept as their layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): keep display discovery results across machine-list polls

refreshDisplays dropped its result whenever refreshGeneration changed, and
every machine-list poll bumps it, so a ~2s guest discovery that overlapped
a poll was discarded: workspace displays loaded only after a later lucky
discovery. Check the lifecycle generation only; the coordinator already
invalidates on identity or image changes. Member-display discovery also
re-checks after each attempt (bounded to three per lifecycle) because a
launch-time refresh can cancel one, verified live: the first attempt's
exec was cancelled after 1ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): member display listed once; silent desktop probe is not unreachable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): list a workspace display once; stop repairing healthy desktops

The sidebar listed a workspace's display twice: cloudWorkspaceResources
appends a copy of a member display carrying its membership view, and
localWorkspaceMembers deduped against the first copy (the pool resource,
without the view), so the local pane added the display again.

Display 1 sat on "Loading Cloud page" for 20s+: the desktop probe's 2s
deadline never cancelled its connection, so a busy carrier held it to the
proxy's 10s header timeout, and the timeout read as unreachable and ran
the control plane's desktop repair (a guest exec, ~12s) on a healthy
desktop. The deadline now cancels the connection, and repair runs only
when the proxy or service answered with an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a New Display pane and its membership are one sidebar row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): build workspace rows and local-pane rows from one resource list

The sidebar built workspace rows from cloudWorkspaceResources (which holds
the membership copy of a member display) but deduped local panes against
the pool resources alone, so a New Display pane listed its display twice.
Both passes now read the same list.

Adds debug.cloudtree.rows (DEBUG only), returning the Cloud sidebar's rows
from the same builder, so dogfood can assert listed rows; duplicate rows
were invisible to cloud tree --json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): display names parse from the daemon graph

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cloud): named displays, display tab titles, remove a display from a workspace

Display panes showed noVNC's page title, and renaming a workspace display
sent a synthetic view id to the daemon's tab rename, which cannot work.
Displays now have one shared name per display, stored as a frontend
projection beside the workspace memberships so every client follows a
rename live. Rows and pane tabs show it ("Display N" until renamed);
renaming the tab or the row renames the display; clearing restores the
number.

Workspace display rows get a hover X and a "Remove from Workspace" menu
item, which close the display's pane on this Mac (the fenced membership
removal) or remove this Mac's membership when no pane is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): list debug.cloudtree.rows as a debug method; fold display names into the membership type

The socket capability guard requires debug methods to be listed as
intentionally unadvertised, and the package conventions lint rejects an
all-static enum, so the display-name constants move onto
CloudVMDisplayMembership.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a display row's Rename names the display

The menu now routes display renames to renameDisplay (one name per
display, shared by every row and pane) instead of a view's tab rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): align two sidebar tests with merged behavior

A connecting machine keeps its Terminals tab (#17326), and every visible
machine row requests a cached port scan once (#17074). Both tests still
asserted the earlier behavior and failed on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): the desktop placeholder is titled Display 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): title display panes from the click, never Desktop first

The display 1 placeholder (shown before guest discovery) was titled
"Desktop" and renamed "Display 1" seconds later; it now uses the same
numbered title as discovery. A new display's pane is titled "Starting
display…" from the click instead of "New tab", then takes the
display's name when it materializes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): standby never takes a recorded number; failed discovery retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): address review: standby race and Displays discovery retry

ensure_standby's locked check now also rejects a number a concurrent
create recorded after the unlocked probe, so a display can never be both
a catalog display and the standby.

Displays-tab discovery reports completion; a failed discovery clears the
request and retries, bounded to three attempts per shown machine, instead
of treating task launch as done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): connecting machines keep only the Resources tab (#17139)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a stale Displays discovery cannot retry after the tab reopens

Drive discovery completions explicitly instead of yielding a fixed number of times.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): bind each Displays discovery completion to its request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): closing a display pane removes it for every client

Drives the real pane-close path. Another client's token in the same
workspace must not rebuild the pane, including when the close lands before
this pane's own token reaches the graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): closed displays stay closed across clients and instances

- Closing a display pane or clicking X removes the display from that Cloud
  workspace for every client. The fence is keyed by machine, workspace and
  display, set at close from the pane's own workspace, and lifted when this
  Mac opens the display there again.
- Pruning only touches view IDs this process recorded: stable and DEV
  builds on one Mac share a client ID.
- A sleeping machine or an undiscovered display does not spend a removal
  attempt.
- Tab renames run in order, and afterwards every pane shows the display's
  real name, so a cleared or failed rename reverts the tab.
- A failed reserved pane drops its "Starting display…" title.
- A reset or refused connection after the tunnel opens reads as unreachable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a desktop that resets the opened tunnel is unreachable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): read a reset after the desktop tunnel opens as unreachable

The probe's errors arrive as NWConnection.StreamError, so the previous NWError
cast never matched. Only the HEAD stage maps a reset or refusal; before the
tunnel opens the same error belongs to the local proxy and stays unknown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a queued close cannot undo a reopen; another client's re-add shows again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): order display removals against reopens; release on another client's re-add

- A reopen bumps the display's generation, so a close or removal retry that
  started before it leaves the reopened display alone.
- Removal retries run on the machine's lane, ordered with the reopen's
  attach; the lane's enqueue is split into a resource-keyed core.
- A removal records the tokens it deleted. A later graph showing any other
  token means another client put the display back, so the fence lifts.
- Attempts are counted per closed display and only while it is fenced.
- A tab rename re-applies the display's name only when it changed nothing or
  failed, so a successful rename no longer flashes the old name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): type the lane failure handler explicitly

The ternary of closures crashed the type checker (failed to produce diagnostic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cloud): a no-op display removal still fences older graphs; proxy reset is unknown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): release a closed display by graph revision, not token sets

A removal now reports the cursor of the graph it was computed from. A graph
no newer than that predates the removal and stays fenced, including when the
removal found nothing to delete. A newer graph that still shows the display
means another client put it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cloud): fence a closed display up to its removal write's own cursor

A graph published between the removal's snapshot read and its write could be
newer than the snapshot yet still hold the removed tokens, releasing the fence
early. The removal now reports the write receipt's cursor (the snapshot's only
for a no-op), and a reply without one keeps the fence until the display is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merged-unverified A judging check was not green at merge; see the merge receipt comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants