Skip to content

cmux NEXT iOS: tab group color, icon, collapse, moves and New Tab in Group; the workspace's screens (switch, new, rename, close) - #27084

Merged
teamleaderleo merged 4 commits into
feat-cmux-nextfrom
feat-cmux-next-ios-groups-screens-parity
Oct 29, 2026
Merged

teamleaderleo merged 4 commits into
feat-cmux-nextfrom
feat-cmux-next-ios-groups-screens-parity

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Parity rows (cmux#25834, slice A tab group rows and slice D screen rows): the iPhone app gets the Mac's tab group appearance and moves, New Tab in Group, and the workspace's screens. No relay, allowlist, acpmux guard or CmuxTabsKit change: every line is one the phone's daemon lane already takes (update-tab-group {color | icon | collapsed}, move-tab-group {group, pane, index}, move-tab-group-to-split {group, pane, edge}, move-tab-group-to-column {group, pane}, move-tab-group-to-new-workspace {group}, new-tab {pane} + add-tabs-to-tab-group, focus-pane {pane}, new-screen {workspace}, rename-screen {screen, name}, close-screen {screen, end_terminals}). Independent of #26644 (based on origin/feat-cmux-next, not stacked).

The design

Tab Group page. A member's menu row Tab Group: … used to open a four-button dialog. It now pushes the group's page (swipe back returns): its tabs (a tap opens one), then

  • Appearance: Rename Tab Group…, Set Tab Group Color… (the Mac's nine colors, current one checked), Set Tab Group Icon… (16 SF Symbols or Emoji…), Remove Tab Group Icon, Collapse / Expand Tab Group;
  • New Tab in Group;
  • Move: Move Tab Group Left / Right (shown only when the group can move that way, the Mac's TabGroupReorder rule), Move Tab Group to New Split › To the Right / To the Left / Above / Below, to New Column, to Workspace…, to New Workspace (not from Home);
  • Pin / Unpin Group, Ungroup Tabs, Close Tab Group.
    Member cards' chip shows the group's color and icon.

Screens. The grid stays one list of every screen's tabs (CmuxTabsKit has no sections and is untouched). Each card names its screen ("Screen 2", or its name) when the workspace has more than one, and the page menu has Screens (N), which pushes a Screens page: one section per screen with its tabs, the current one marked, a ⋯ per screen (Select Screen, Rename Screen…, Close Screen) and + (New Screen).

Why pushed pages: the user's drill-down rule (NavigationStack push, swipe back), room for the Mac's whole group menu (a dialog can't hold 20 rows with submenus), and no CmuxTabsKit change.

Notes on semantics

  • Select Screen uses focus-pane on the screen's active pane. The daemon's select-screen {index, delta} names no workspace: it picks among the screens of the daemon's active workspace, so it can't aim at the workspace the phone shows. focus-pane makes that pane, its screen and its workspace active in one request. Like any focus change it also makes the workspace the daemon's active one; the Mac's windows keep their own shown screen (the Mac's own screen switch is window-local).
  • Move Tab Group to Workspace… is move-tab-group into the destination's first screen's default pane (never a chat dock), as the Mac's tabGroup.moveToWorkspace. The slice audit listed it as off the lane; move-tab-group is on it, so it is done.
  • Close Screen ends the screen's terminals (end_terminals): the phone has no Reopen Closed Screen, as a phone tab close ends its terminal. It asks first when a tab on it runs a program; not offered on the only screen.
  • A group in an agent chat dock is not split ("The agent chat can't be split.", the Mac's ChatDockRules).
  • Not done: Move Tab Group to New Window (no windows on a phone); screen duplicate / reopen / color / icon / pin / move / groups (other slice D rows).

HIG: the HIG pages (pull-down buttons, lists) render client-side and could not be read from this sandbox (empty body); the pages follow the patterns the app already uses (inset grouped lists, pull-down menus in rows, destructive item last, pushed drill-downs).

Evidence (real link)

The phone app ran in one headless simulator (groups-phone) on mini-cmux11s against the tagged cmux NEXT Mac app feat-cmux-next-ios-groups-screens-parity (hidden, isolated, rig folder ~/cmux-next-ios-e2e/groups). Every action was done on the phone. After each one, the Mac's tree was read back from the rig app's own daemon (list-workspaces, verify/parity/pq_groups.py). Before is the base app (09b9b3f, fleet step 17d3cd96); after is this branch (c03efa5, fleet step cb5ea816; the later b72eafa changes only the rig script).

Row Before After (the Mac's tree, read back)
Set Tab Group Color… / Icon / Collapse the group dialog has only Rename, Pin, Ungroup, Close build: red → purple (0.0 s), icon star → none, collapsed true → false
Move Tab Group Right / Left none pane t1, t4, t2, t3 → t1, t2, t3, t4
New Tab in Group none the group has 3 tabs (a new terminal, 0.1 s)
Move Tab Group to New Split › Below / New Column none the group in a new pane, then in a second column
Move Tab Group to Workspace… / New Workspace none the group in p2tabs-groups-dest's pane (the source keeps t1, t4), then in new workspace workspace-5
Screens: card chip, Select, New, Rename, Close one list, no screens anywhere; no Screens in the page menu cards show "Screen 1 / Screen 2"; a third screen (0.0 s); name logs; Screen 1 active; logs closed

Screenshots, recordings and per-step JSON are in the HQ proof report at .cmux-scratch/ios-parity/proof/feat-cmux-next-ios-groups-screens-parity/index.html.

Tests

  • CmuxMobileKit on the iOS simulator, fleet step cb5ea81648b286f7b76219da at c03efa5: exit 0, 1386 passed, 0 failed, 1 skipped. The run at 96a3cdf (f4da6f42) had 2 failures in the new test, both found by the review and fixed.
  • New tests: GroupsScreensLiveTests (the tree's screens, group look, pane slots and chat docks; the Mac's move index; the exact daemon lines for appearance, moves, New Tab in Group and screens; the phone's refusals) and WorkspaceTabsSourceTests.tabGroupPageActions / .screensFromThePageMenu.
  • No CI job, gate or test lane added.

Observed / Not run

  • Observed: the three flows above, before and after, on the real link; the kit suite on the fleet.
  • Not run: no XCUITest added. The card's screen chip is proven by screenshot, not by an AX query (the card button merges its children's labels). The Mac app has no change.

Tagged build: feat-cmux-next-ios-groups-screens-parity, fleet job 9fdcfa6c1f83ba024069aac0 at b72eafa, published.

Changelog

Added: in cmux NEXT for iPhone, a tab group page sets the group's color and icon, collapses it, moves it (left, right, to a new split, column or workspace) and opens a new tab in it; a Screens page shows the workspace's screens and switches, adds, renames and closes them, and each tab card names its screen.

🤖 Generated with Claude Code

azooz2003-bit and others added 4 commits October 27, 2026 21:40
…Group; the workspace's screens (switch, new, rename, close)

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

Any agent_chat dock never splits (as ChatDockRules.isChatDock); Move Tab Group to Workspace
aims at the first screen's default pane, as the Mac; New Screen and a tab row open the tab;
pushed pages keep the tab bar hidden; image icons draw as a picture; test expectations follow
TabNotice's wording and the mock's refusal. pq_groups.py drives the flows on the rig;
pq_tabs.py finds the daemon socket under /tmp too (as #26644).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ip, in the part of the page a card shows

The rig run showed no chip: a card shows only the top of the page, and the chip was at its bottom.
pq_groups.py: rows found by label start, menus and pull-downs, the picker by OCR, the grid as opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t removes the icon

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@azooz2003-bit azooz2003-bit added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review b72eafa: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr27084 -f ref=feat-cmux-next-ios-groups-screens-parity -f platform=macos -f backend_mode=local

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Correctness review (fresh subagent, given the diff at 96a3cdf) and resolution

A fresh review subagent read the diff against origin/feat-cmux-next and the Mac and daemon sources it mirrors (TabGroupReorder, TabGroupHandlers+Moves, ScreenCommands, cmux-tui-core cmd_screens.rs / topology_edit.rs / tree_json.rs / screen_json.rs). It did not build anything. What it found, and what was done:

# Severity Finding Resolution
1 High tabGroupPageActions expected "Set Tab Group Color… didn't work…", but TabNotice strips the … Fixed the expectation (8b4d142); the fleet run at 96a3cdf had failed on exactly this
2 High The same test expected the refused edit in groupEdits, but the mock's settle() throws before it records the edit Fixed the expectation (8b4d142)
3 Medium New Screen left the Screens page open, though the rig and docs say it opens the new tab newScreen() now sets route = nil and openedTab, as New Tab in Group does
4 Medium The chat-dock check left out a pinned dock with allow_tabs, but the Mac never splits any agent_chat dock (ChatDockRules.isChatDock) chatDockPanes now covers every agent_chat dock
5 Medium Select Screen (focus-pane) also makes the workspace the daemon's active one Kept on purpose: select-screen names no workspace and can only pick among the screens of the daemon's active workspace, so it can't aim at the workspace on the phone. The side effect is now in the code comment and in parity-mac.md
6 Low Move Tab Group to Workspace used the destination's active screen; the Mac uses screens.first with its default pane Now matches the Mac
7 Low Tapping a tab on the group page selected it but left the grid open Now also sets openedTab; same for a tab row on the Screens page
8 Low The pushed pages could bring back the tab bar Both pages now hide it
9 Low If add-tabs-to-tab-group fails after new-tab, New Tab in Group leaves an ungrouped tab Not changed; this is the Mac's own two-step tabGroup.newTab
10 Low image: and svg: icons drew as text They now draw as a picture glyph (the phone has no copy of the image)
11 Low Leftover renamingGroupTab Removed

The reviewer also confirmed these are correct: every command and field is on both lane policies (the Mac's DaemonLanePolicy and cmux-mobile-host policy.rs), the move index matches TabGroupReorder, tree parsing matches the daemon JSON, every switch covers all cases, older saved data still loads, and each GroupsScreensLiveTests expectation holds by hand trace.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Bead cx-6ealt9 created for this PR by the cmux coordinator.

@teamleaderleo
teamleaderleo merged commit 4cbe952 into feat-cmux-next Oct 29, 2026
160 of 174 checks passed
@teamleaderleo
teamleaderleo deleted the feat-cmux-next-ios-groups-screens-parity branch October 29, 2026 08:15
@teamleaderleo

Copy link
Copy Markdown
Collaborator

Reviewed SHIP at b72eafa (independent Codex gpt-6-astra review), merged as 4cbe952 on Leo's OK to land teammates' iOS PRs once they check out. The only red, TerminalSurfaceFramePacingTests/busyOutputDrawsAtMostOneFramePerDisplayRefresh ("too few batches"), fails with the same assertion on feat-cmux-next. Fleet iOS sim at c03efa5 (head differs only in verify/parity/pq_groups.py) and CI kit tests at head passed. Heads up: #26644 and #26478 conflict with this; their fixes are being carried on repair PRs that merge this in.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

Review (codex-review-box/gpt-6-astra high, lane-place): SHIP at b72eafa

SHIP

@github-actions

Copy link
Copy Markdown
Contributor

The feat-cmux-next push run https://github.com/manaflow-ai/cmux/actions/runs/40841001623 at c8ead60 failed: cmux-next checks (god files, concurrency, crash safety, l10n).
Those jobs last passed at e73a067. This pull request is one of 2 merged since: #27084, #27277.
If the failure is in your change, fix forward on feat-cmux-next. Pull requests run only the tiers their paths reach (docs/ci/cmux-next-tiers.md); label a batch PR full-ci to run them all before merging.

@github-actions

Copy link
Copy Markdown
Contributor

The feat-cmux-next push run https://github.com/manaflow-ai/cmux/actions/runs/40843680719 at db559ad failed: cmux-next checks (god files, concurrency, crash safety, l10n).
Those jobs last passed at e73a067. This pull request is one of 4 merged since: #27084, #27277, #26638, #27302.
If the failure is in your change, fix forward on feat-cmux-next. Pull requests run only the tiers their paths reach (docs/ci/cmux-next-tiers.md); label a batch PR full-ci to run them all before merging.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

teamleaderleo added a commit that referenced this pull request Oct 29, 2026
…own modifier

The merge of #27084's route destination with #26644's Fork Conversation
dialog made WorkspaceScreen.body's modifier chain too long for the type
checker ("unable to type-check this expression in reasonable time",
WorkspaceScreen.swift:107, fleet step 4af59fb5). The navigation
destination (Screens, Panes, a tab group) and the Fork dialog move to
WorkspacePages, as TabMoveAndGroupSheets already holds the sheets. No
behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725
teamleaderleo added a commit that referenced this pull request Oct 29, 2026
* cmux NEXT iOS: Panes page (split, new pane, zoom, swap, rename, close, equalize, undo layout), tab moves to a split, column or pane, and fork destinations

The phone reads each workspace's panes from the Mac's tree (screens, split tree, panes and
their tabs) and a page menu item pushes Panes: a picture of the shown screen's splits and one
section per pane with its tabs. Pane menus send the Mac's split, new-pane, new-pane-right,
zoom-pane, swap-pane, rename-pane and close-pane; the toolbar sends undo-layout (with the
daemon's close confirmation) and set-split-ratio (Equalize Splits, one transaction). A tab's
menu sends move-tab-to-split, move-tab-to-column and move-tab across panes. Fork
Conversation… composes the fork with move-tab-to-split or move-tab-to-new-workspace, as the
Mac's openFork does. No lane, relay or CmuxTabsKit change.

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

* cmux NEXT iOS Panes: pane actions run their Mac request on the main actor (Swift 6 sending check)

Also the rig: pq_panes.py flows, and pq_tabs finds the app's daemon socket under /tmp too
(as on #25894 and #26478).

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

* cmux NEXT iOS Panes: compile fixes (own request helper, Transport import, no self-shadowing)

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

* cmux NEXT iOS: the chat menu test reads Fork Conversation… and its destination

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

* cmux NEXT iOS Panes: review fixes

New Pane splits the largest pane along its longer side, as the Mac's New Pane (Auto
Layout) does (never the daemon's new-pane, which lays the column out again). Rename Pane…
starts with the current name. Move Tab to Pane goes past every tab the pane holds on the
Mac. A refused equalize lists the tree again. The applied undo reply needs its screen and
revision. Dead panes leave the split tree; only the shown screen's active pane is Focused.
A Home chat's fork is found among Home's tabs. Tests: a stateful mock Mac (the split's new
pane and the fork's tab appear only after the request), auto layout and dead pane cases.

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

* cmux NEXT iOS Panes: red tests for the undo screen switch and viewport-column Auto Layout

Two review findings on #26644, as tests that fail at this commit:
- the Mac switches screens while Undo Layout Change's question is open;
  the yes must still name a pane of the screen the question was about
  (it names the newly shown screen's pane 60 instead of pane 1).
- three viewport columns two thirds of the viewport wide and a docked
  chat column: New Pane (Auto Layout) must split the first scrolling
  column right (it splits the docked column down instead, reading the
  compatibility chain's one-width geometry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: Undo Layout Change's yes goes to the screen it asked about

Review finding (P1): the confirmation kept only the revision and named
the shown screen's focused pane again. If the Mac switched screens while
the question was open, the yes went to the other screen, and since
revisions count per screen a matching one could undo an unrelated
change there, closing panes.

The question now keeps its screen and the pane that named it
(LayoutUndoConfirmation), and the yes sends exactly that pane with the
revision. When that pane is no longer on that screen, the phone refuses
before sending anything ("The layout changed on the Mac...").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: New Pane (Auto Layout) measures viewport columns at their widths

Review finding (P2): Auto Layout read the screen's compatibility chain,
which squeezes every viewport column into one 16x10 rect. Three columns
two thirds of a viewport wide computed as 5.33x10 and split down; on
the Mac each is 10.67x10 and splits right.

The phone now keeps the daemon's columns (width, dock, rows with their
heights) and measures panes as the Mac's PanePlacement.autoSplit does:
each scrolling column at its own width of a 16:10 window, rows at their
heights, and a docked column never split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: Move Tab to New Split counts every tab the pane holds

Second review finding: the menu checked the phone's listed tabs
(tabIDs.count), so it hid Move Tab to New Split when the pane also held
tabs the phone doesn't show. The daemon only needs the pane to keep
another tab, of any kind: the check is tabCount now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: a fork's toast names the workspace on the chat's own Mac

Second review finding: after Fork Conversation to a new workspace, the
toast looked the copy's surface id up across every Mac's workspaces, so
another Mac's workspace with the same surface id could be named. The
lookup now keeps to the chat's Mac. Test: another Mac lists a tab with
the same id first; the toast still names "Forked Here".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: explicit CGFloat in the column geometry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: read undo-layout's question strictly

Review finding (P2, on the repair): the confirmation preview was taken
without a typed screen and with any closes_panes array. A question now
needs its integer screen, equal to the screen the phone asked about, a
non-negative revision, and a non-empty list of integer pane ids, as the
daemon's undo-layout reply defines them; anything else is a protocol
error, so no malformed question is shown or confirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: Auto Layout leaves the docks' room out of the strip

Review finding (P2, on the repair): scrolling columns were measured at
the window's full height, though a top or bottom band takes its height
from the strip (in either mode), and at the full window width, though a
docked side dock takes its width from it. The columns now keep their
dock's edge and mode, and Auto Layout measures the strip as the Mac's
DockStripGeometry.place does (less gaps and pixel minimums, with its
share caps): a column is its share of the strip's width at the strip's
height. Also refuses a negative pane id in an undo question (P3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: autoLayoutFrames is public (the Live tests read it)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS Panes: tests name the dock types and an overlay chat dock

The viewport-column test's chat dock floats (overlay), so the strip
keeps the full width it measures; a docked one 90% wide would leave a
narrow strip, as on the Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

* cmux NEXT iOS: the workspace's pushed pages and Fork dialog in their own modifier

The merge of #27084's route destination with #26644's Fork Conversation
dialog made WorkspaceScreen.body's modifier chain too long for the type
checker ("unable to type-check this expression in reasonable time",
WorkspaceScreen.swift:107, fleet step 4af59fb5). The navigation
destination (Screens, Panes, a tab group) and the Fork dialog move to
WorkspacePages, as TabMoveAndGroupSheets already holds the sheets. No
behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-b1bccf963188dc990725

---------

Co-authored-by: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
azooz2003-bit added a commit that referenced this pull request Oct 29, 2026
…pending reveal on teardown

After merging feat-cmux-next:
- Rename Pane, Rename Screen, Rename Tab Group and Set Tab Group Icon
  (new in #27084) make their confirm the preferred action, as the other
  text-entry alerts do here (HIG Alerts). The old Rename Tab Group alert
  in TabMoveAndGroupSheets is gone on the base.
- A window that closes while its overlay panel waits for its first frames
  stops that reveal (the base now closes the panel on teardown).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
teamleaderleo added a commit that referenced this pull request Oct 29, 2026
…base tests

WorkspaceTabsSource (#27087) lists store changes on the next main-queue turn and reads each
tab's listed copy. Four tests from base (#27084, #27322) changed the store and read the source in
the same turn, so they saw the old tabs. They now call `source.refreshTabs()` first, as #27087's
own WorkspaceTabsSourceListTests do: restartShellOnlyOnAnEndedShell, tabGroupsFromTheTabMenu,
tabGroupPageActions and aBlankBrowserTabHasNoAddress (fleet kit run at 3f8df7b, step
48cd3bbd368434dadcb35501; none fail on base c00c321).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-89c8bbec072d313a51cd
azooz2003-bit added a commit that referenced this pull request Oct 31, 2026
…OS, web, iOS) (#27638)

* debug: record a dialog's open and close frame by frame (debug.dialog_strip)

DEBUG builds only. Reads the active window and its overlay panel (where
dialogs draw) back to back while a debug.dialog fixture opens and closes,
and reports per-frame card and scrim progress, intermediate frames,
settle time, the largest progress step and the main actor's present and
close time, with a PNG per frame. Evidence for the dialog motion work.

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

* debug: dialog strip measures the card in frame pixels

A recorded frame is the window's backing pixels times the scale, so on a
2x display the card rect (points times scale) was half size and off the
card. Derive the pixels per point from the first frame.

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

* test(dialogs): destructive and key-hint ink, a distinct default button, grow-in and key-window glass

Regression tests for the alert polish, red on the base:
- a dialog's destructive title and every key hint read at 4.5:1 on their
  fill over the card (the theme's danger is fit only to 3:1; over the
  dialog glass it measured 2.1:1 with debug.dialog_strip);
- the default button's fill differs from Cancel's at least 3:1 (it was
  a gray 3 to 4% denser);
- the card grows in from Motion.panelOpenScale on the appear spring,
  only fades under Reduce Motion, and a closed card starts whole again;
- the card's glass keeps the key-window look (the close lit it up grey).

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

* fix(dialogs): smoother open and close, AA contrast for every dialog button

Every cmux dialog (CmuxDialogCenter, about 100 call sites) draws through
CmuxDialogView on WindowOverlayHost. Measured with debug.dialog_strip
(window-server frames, before):

- the card only faded, flat; it now grows from Motion.panelOpenScale
  about its center on the appear spring, as the palette opens (fade only
  under Reduce Motion), and shrinks toward panelCloseScale on fadeOut;
- the scrim eases on the scrim token, the one the inactive-window tint
  under it uses, so the window darkens in one step, not two;
- the close lit the card up grey for the whole fade: its overlay panel
  gives key back as the fade starts and the glass took its inactive
  frost. The card's glass keeps the key-window look (keepsActiveLook,
  as the window backdrop does);
- the destructive title read 2.1:1 over the glass (the theme's danger is
  fit to 3:1). CmuxDialogButtonLook fits every ink to 4.5:1: the default
  button is the solid accent fill with on-accent ink (the web pages'
  .primary), a destructive one a solid danger fill (the web's .danger;
  Debug Settings 'Filled destructive dialog button' off shows the tinted
  variant), cancel and normal the selection gray. The focus ring moves
  just outside the button so it shows on a solid fill.

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

* fix(webviews): dialogs grow in and out like the native dialog

A page dialog (ui/Dialog) only faded, and its backdrop shared the card's
120 ms fade. As the native dialog now does: the dialog grows from 0.97
while it fades in and shrinks toward 0.98 as it fades out; the backdrop
eases on the standard (scrim) step. Reduce Motion (system or the app's
data-cmux-motion=reduced) keeps only the fades; popup-reduce-motion
covers both.

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

* fix(ios): alerts make the likely choice their preferred action

Text-entry alerts (Rename, New Group, Create Team, Join, Send, Set, Open,
Save, Create) paired a Cancel button with an unmarked confirm, so iOS drew
Cancel bold and Return pressed nothing. The confirm is now the preferred
action (.keyboardShortcut(.defaultAction): bold, Return), and Message Not
Unsent's Try Again its preferredAction. HIG Alerts: place the button people
are most likely to choose as the default. Destructive confirmations keep
their role and are never the default. The iOS alerts are system alerts:
their animation and colors are the system's, and the root tint is the
label color, so their buttons already read at full contrast.

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

* test(dialogs): the overlay panel attaches with the dialog still transparent

Red on the base: in about half the opens the window server showed the
whole dialog and scrim for one frame before the fade (debug.dialog_strip,
base build too), because attaching the panel displays its layers at full
opacity.

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

* fix(dialogs): no full-opacity frame before a dialog's fade-in

Attaching the overlay panel (syncPanel) displays and commits its layers
at once, so in about half the opens the window server showed the whole
dialog and scrim for one frame, then the fade began from zero: a flash.
The card and scrim go transparent, with no implicit animation, before
the panel attaches; a dialog shown again mid-close keeps what is on
screen.

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

* test(dialogs): a focused dialog button draws an unclipped ring outside its fill

Red at the previous head: the solid default button showed no focus ring
in the tagged build (debug.window_snapshot).

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

* fix(dialogs): the focus ring shows around a solid dialog button

The ring layer, added in init, did not survive AppKit giving the button
its backing layer, and the button could clip it. It joins the current
layer on layout and the button no longer clips, so keyboard focus shows
on the solid default and destructive buttons.

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

* fix(motion): a gated set commits its model value and animation together

Motion.set wrote the model value and added the animation in two
transactions. With none already open (a socket, timer or task turn),
each commits to the render server on its own, so a frame could show the
new model value before the animation that starts from the old one. A
cmux dialog flashed in at full opacity for one frame, then faded in from
zero: 1 open in 5 after the panel-attach fix (debug.dialog_strip,
window-server frames). Every spring, fade and timed set now runs in one
outer transaction, which fixes the class for every gated layer
animation, not only dialogs.

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

* test(overlays): a freshly attached overlay panel shows only after it drew

Red at the previous head: ordering the panel in showed its old surface
(the last dialog at full opacity) for a frame before the new dialog's
fade (debug.dialog_strip: 2 in 10 opens with a fresh attach, 0 in 20
with the panel already attached).

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

* fix(overlays): a freshly attached overlay panel shows only after it drew

Ordering the overlay panel in shows its window surface at once, and that
surface still holds what the panel last drew on screen, often a dialog at
full opacity, before this turn's commit reaches it: one frame of an old
dialog and scrim, then the new one fading in from zero (debug.dialog_strip:
2 opens in 10 when the panel attached fresh, 0 in 20 while it stayed
attached). The panel orders in transparent and shows after two display
frames (FrameClient on its display link, no timer); a window on no screen
shows it at once. An attached panel is untouched.

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

* fix(dialogs): review fixes: hover hue, focus ring gap, ink fit, debug timer

From the correctness review:
- Cancel and normal buttons keep the selection fill's hue on hover and
  press (an accent theme's selection is tinted; pressedFill is gray).
- The focus ring leaves a 2 pt gap, so it reads apart from the accent
  default button's fill.
- readable() repeats until the ink reads on every card surface (a move
  for one surface could lower another).
- debug.dialog_strip keeps its pause timer alive across the await.

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

* fix(webviews): dialog grow-in uses scale, sheets only fade, backdrop not cut

From the correctness review of the dialog motion:
- the grow-in is the scale property, so a dialog placed with a transform
  (Home palette and forms: translateX(-50%)) keeps its place, and gets
  the grow-in too; under reduced motion it no longer slides sideways;
- a sheet (also .ui-dialog) only fades again;
- the backdrop's close matches the dialog's: Base UI unmounts both when
  the dialog's transitions end, which cut the longer backdrop fade.

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

* fix(ios, overlays): preferred action on the base's new alerts; end a pending reveal on teardown

After merging feat-cmux-next:
- Rename Pane, Rename Screen, Rename Tab Group and Set Tab Group Icon
  (new in #27084) make their confirm the preferred action, as the other
  text-entry alerts do here (HIG Alerts). The old Rename Tab Group alert
  in TabMoveAndGroupSheets is gone on the base.
- A window that closes while its overlay panel waits for its first frames
  stops that reveal (the base now closes the panel on teardown).

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

* test(overlays): the panel leaves only after its empty frame; a re-attach cancels the detach

Replaces the fresh-attach reveal test. Red at the previous head: the panel
attached transparent (alpha 0) for two frames and left in the turn its
content did.

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

* fix(overlays): order the panel out only after it drew empty, never hide it on attach

Review of c646e47: hiding a freshly attached panel for two frames
also hid the page planes (focus ring, inactive-pane dim) and the agent
cursor it carries when the first browser page appears, and delayed every
first overlay in a window by two frames.

The stale surface comes from the detach, so the fix goes there: the
panel leaves its window only after AfterFrameCommit, once the frame that
draws it empty has committed (a re-attach in between cancels the detach).
Any later attach shows at once, with no hidden frames, no display-link
client and no timer. Teardown stays synchronous.

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

* style(ios): align the Set Tab Group Icon default action with its chain

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

* fix(overlays): a leaving panel is detached for the host at once; only its order-out waits

AgentCursorGlassBlurTests caught it: with the order-out deferred a
commit, isPanelAttached stayed true for that commit, so the agent cursor
and the window's planes (WindowOverlayLayer.overlayPanel) stayed on a
panel that was leaving. The host now stops using the panel in the same
turn (isPanelAttached false while the detach is pending: the cursor and
planes go back to the window at once), and only the physical order-out
waits for the empty frame.

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

* test(overlays): the detach test reads the panel's child-window state

isPanelAttached is the host's view (false at once on a detach); whether
the panel is still on the window, with its surface, is its child-window
membership.

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

* test(dialogs): a focused button's ring stays clear of the next button

User report: the focused button's border overlapped the adjacent
controls. Red at the previous head: dialog buttons are space2 (4 pt)
apart and the ring reached 4 pt past its button, ending on the
neighbor's edge. The ring must stay at least 1 pt clear.

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

* fix(dialogs): the focus ring stays within its button's slot

The focus ring reached 4 pt past the button (a 2 pt line after a 2 pt
gap), and dialog buttons are Metrics.space2 (4 pt) apart, so a focused
button's ring ran up against its neighbors (user report, 2026-10-29).
The ring now reaches only its 2 pt line past the edge, leaving 2 pt to
the next button. On a solid fill, a 1.5 pt line of the card's color just
inside the edge keeps the ring apart from a fill of the same color (the
accent default button). The older focus test picks the ring as the
widest bordered layer, now that the gap line is one too.

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

* fix(overlays, dialogs): re-route the mouse when a panel starts to leave; focus gap in the real card color

From the correctness review of the final delta (SHIP, two low findings):
- syncPanel routes the mouse again after the agent cursor moves off a
  leaving panel, so the empty panel stops catching clicks and its mouse
  monitor ends at once instead of when it orders out.
- The focus ring's inner gap uses the card the dialog actually paints:
  the opaque surface under Reduce Transparency, else the glass.

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

* test(dialogs): a focused solid button keeps its full fill

Visual review of #27638 (real-app capture at ed9cff0): the focus
band of a solid button was drawn inside its bounds, so the focused Save
looked 3 px smaller on every side than its neighbors. Red at ed9cff0.

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

* fix(dialogs): a focused solid button keeps its full size; the gap sits outside it

From the visual review of #27638: the focus gap was a band inside the
button's edge, so a focused solid button (Save, Don't Save) looked 3 px
smaller on every side and its edges no longer lined up with its
neighbors'. The gap is now a 1 pt band just outside the fill, then a
1.5 pt ring: 2.5 pt past the edge, still 1.5 pt clear of a button 4 pt
away (the neighbor test holds). A translucent fill shows no gap band; its
ring sits in the same place.

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

* test(dialogs): the focus ring reads at 3:1 on the dialog card

Visual review of #27638 (real-app capture at c45470c): the light
focus ring measured 2.73:1 on the card, under WCAG's 3:1 for a focus
indicator. Red at the previous head (no fitted ring color).

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

* fix(dialogs): the focus ring reads at 3:1 on the dialog card

Visual review of #27638 (real-app capture at c45470c): the light
focus ring was the theme's focus ring as is, rgb(134,134,134) on the
card's rgb(223,223,223), 2.73:1, under WCAG's 3:1 for a focus indicator.
On a gray Cancel it is the only sign of focus. CmuxDialogButtonLook now
fits it to 3:1 on both card surfaces, as it fits titles to 4.5:1; dark
(3.81:1) and an accent ring that already reads keep their color.

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

* test(dialogs): the focus ring reads at 3:1 on the card as it really renders

Real-app capture at 1dcfb3f: the light ring still measured 2.66:1.
It was fit to an estimate of the glass card (about rgb 241), but the
real light card renders as the scrimmed window, rgb(223,223,223). Red at
1dcfb3f.

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

* fix(dialogs): fit button inks and the focus ring to the card as it really renders

Real-app capture at 1dcfb3f: the light focus ring still read 2.66:1.
Inks were fit to an estimate of the glass card (the veil over the
scrimmed window, about rgb 241), but Liquid Glass shows little of its
veil and the light card renders as the scrimmed window, rgb(223,223,223).
cardSurfaces now includes the scrimmed window, so every title, hint and
the focus ring are fit on it too, and the focus gap takes its color.

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

* fix(dialogs): drop the destructive-style Debug toggle; filled is the shipped style

The defaults guard flagged the new Debug Tunable (dialog.filledDestructive,
on) and, through a local variable named `color` (also a Tunable helper's
name), CmuxDialogButtonLook.readable and minimumContrast. The toggle only
compared two styles for the design call; filled was already what ships.
The tinted alternative stays as CmuxDialogButtonLook's
`filledDestructive: false`, covered by its contrast test, for
#13742. The local is renamed. Nothing anyone gets
changes.

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

* test(dialogs): wait for the frame commit without importing CmuxNextWakeups

fast gates on 59993c3: CmuxNextDesignTests imported CmuxNextWakeups
(for AfterFrameCommit) without declaring it, which the base's package
dependency rule now refuses. The test waits on the same signal with a
one-shot CoreFoundation before-waiting observer instead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
teamleaderleo added a commit that referenced this pull request Nov 1, 2026
…#27087) (#27664)

* cmux-next-ios: list scroll rig for every vertical list (DEBUG only)

ListStressRig seeds workspaces, tab grid, agent chat, diff, Home or the Feed,
opens it and scrolls its UIScrollView top to bottom and back with one
FrameIntervalLog window per direction. verify/listperf/rig.sh builds and runs
it on a fleet Mac's simulator; matrix.sh is the phase-1 measurement set.

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

* cmux-next-ios list rig: transcripts scroll from the top, open windows, memory, sample mode, minis

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

* cmux-next-ios list rig: lane brief

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

* cmux-next-ios Feed store: no per-write scans or main-thread file writes

Read marks are written on a background queue (load waits for queued writes),
checked against a Set, and items are indexed by id, so item(id:), mutate and
mergeHistory no longer scan. The Mac's reads land in one write, and the ledger's
echo check is computed once per read (binary search per terminal row) instead
of every entry against every other on each tree update.

Tests (aws-m4pro-5, swift test --filter FeedStoreScale|FeedStoreChurn|FeedReadMarks|TerminalFeed|Persistence):
48 tests in 10 suites passed. New: FeedStoreScaleTests (index follows writes,
one write for the Mac's reads, read marks reload in order, echo set equals the
old pairwise check).

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

* cmux-next-ios workspaces: red tests for stable section ids, one inbox build per change, lookup moves

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

* cmux-next-ios home: red test for one Home list build per change and a result revision

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

* cmux-next-ios list rig: diff pan, wrap and split modes, artifacts grid screen, stills

ListStressRig gains LIST_PAN (sideways passes, <screen>-right/left-N windows),
LIST_WRAP / LIST_SPLIT for the diff screen, LIST_HOLD_X with LIST_PASSES=0 for
stills, and an artifacts screen (the chat's Files and Artifacts grid; each
replayed turn names a screenshot, a page and a PDF). The mock source renders its
screenshot and PDF once so the rig measures the app's decode. rig.sh gets DEVICE
for sim and a shot mode; summarize.py pools the pan windows. DEBUG only.

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

* CmuxTabsKit: build only the grid cards near the screen, keep the hidden grid dormant

The tab grid built and laid out every card (full-page content under a
GeometryReader and scale, a shadow and a UIKit interaction view) in a plain
ZStack, on screen or not, and it stays mounted at opacity 0 while a page
shows, so every change on the page re-ran it.

- GridBand: the grid builds the cards within the visible rect plus a row
  of margin, snapped to whole rows (the band changes once per row
  scrolled, not per frame). Cards a gesture or the zoom holds (swiped,
  lifted for the menu, dragged, the zooming tab) stay built. Absolute
  layout, swipe to close, reorder, menus, Select Tabs and the zoom are
  unchanged.
- DormantGate: while the grid is hidden its parent's updates are held
  back (equatable gate); it wakes with current values when it starts to
  show.
- The active tab's card content is not built while its page is shown or
  zooming (the page draws it); a streaming chat's card re-rendered every
  token under the page.

Public API unchanged. Tests: GridBandTests (bounded band for 80 tabs,
one change per row, next row prebuilt).

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

* cmux-next iOS tabs: stored kit tabs, per-tab observation, per-card lookups

WorkspaceTabsSource.tabs mapped every tab through kitTab on every read
(several times per pass), reading each chat's displayTitle (its turns),
each page's title and progress and each terminal's status, so a token or
a progress tick re-ran the tabs screen and the whole grid. Cards read
source.workspace (any Mac publish re-ran every card).

- tabs is a stored array rebuilt by refreshTabs() when the workspace
  changes and per tab when that tab's live inputs change; it is published
  only when a kit-visible field changes (unchanged tabs reuse their kit tab).
- Cards, pages and accessories read their own TabEntry (one tab), not the
  workspace.
- Agent cards show a turns copy (AgentCardFeed) that follows the chat only
  while the grid shows and is not scrolling.
- Terminal cards: hosts and the first screen read are made when the tab is
  listed (prepareCard), and card(for:in:) returns one cached view per slot
  (no host, Task or new AnyView per body).
- The grid header is its own view (the workspace title and tab count).
- List rig: logs whether the tabs case's stream runs.

Tests: WorkspaceTabsSourceListTests (no rebuild on another workspace's
publish, one tab rebuilt on rename, per-tab entry observation).

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

* cmux-next-ios lists: Workspaces and Home rows skip unchanged rows; menus on demand; render-server shimmer

Workspaces: the inbox is memoized once per change of its inputs (it was built
about three times per body pass); rows are an Equatable view of plain values
(WorkspaceRowModel), so an update re-renders only changed rows; the long-press
menu is its own view read when shown (it scanned the Mac's rows and drew nine
swatches per row per pass); swatch images and SF Symbol checks are cached;
section ids no longer include the first row (a new top row tore its section
down); Move Up / Down availability is a lookup; the list animates on memoized
keys instead of id arrays; one list-level live check replaces a per-row
onChange; the working shimmer is a Core Animation sweep (no TimelineView body
per frame per row); RelativeStamp reuses its formatter.

Home: the filtered list is memoized and animates on its revision; rows are an
Equatable view with menus built on demand; MessagesStamp works out its day
boundaries and styles once per stamp; the typing dots pulse on the render
server. Devices: same-name tags and other-build rows once per pass, not per row.
Drawer: the touch-down walk does not descend into a vertical list's cells.

Tests (mini-cmux7s, swift test --filter): WorkspaceInboxIdentityTests,
HomeListMemoTests, MessagesStampTests, WorkspaceGroupSectionsTests,
WorkspaceInboxOrderTests, MacFilterTests, MacHomeTests: 49 passed.

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

* cmux-next-ios list rig: FAMILY and PRODUCTS for an iPad build (diff Split stills)

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

* cmux-next-ios diff and artifacts: O(visible) diff list, prepared off main; cheap thumbnails and code preview

Diff file screen: a UICollectionView (DiffCodeList) with one cell per line, split
pair, hunk header, gap or "Expand N lines", laid out at heights prepared once per
(file, options, code size, width) in DiffRenderModel (rows, attributed code with
word highlights, wrap indents), built off the main thread for long files. The
unwrapped unified code pans sideways as the collection view's own content: the
gutter and band labels are pinned by moving them in visible cells only, so a pan
frame runs no SwiftUI body (was ~50 ms frames). Per-line context menus are built
on demand; expand context reconfigures only the bands that kept their id.

Model: DiffFile counts additions/deletions and the viewed fingerprint once per
content (DiffViewedStore.key no longer hashes every line per lookup; same key),
memoizes the edits line diff per edit list, and hunks(context:) is one forward
pass (the per-hunk backward declaration search was O(hunks x lines) and stalled
the transcript 35-48 s on the rig's 2400-line rewrite). Changes list reads the
viewed set once per body; rows are Equatable.

Artifacts: thumbnails decode with ImageIO downsampling and draw PDFs with
CoreGraphics off main, at most two HTML snapshot web views at once, in an NSCache
keyed by artifact id and kind; a cancelled load is no longer recorded as a
failure forever. The code preview splits and highlights once off main with a
linear tokenizer (was O(L^2) per line in body); the image preview decodes once;
browser and sheet derive shown items and chip counts once per body.

Tests: DiffPrepareTests (memo counts, fingerprint parity, rows/hunks/split/
whitespace, linear hunks on 2400 lines, linear highlighter, filter counts,
thumbnail memo keyed by id and retried after cancellation) pass on
mini-cmux10s with DiffLayout, DiffViewedStore, Round3DiffArtifact, AgentChat
and ArtifactExcerpt suites.

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

* cmux-next-ios workspaces: a tree update re-hosts no toolbar menus and no shell

Sampled live updates (200 workspaces, 8 changes/s, no scrolling): main busy
8790 -> 3617 samples in 30 s. ShellView's body read store.workspaces,
activeWorkspaces and macData through onChange, so every Mac tree update
re-ran it and rebuilt the drawer, the tabs and the toolbars: those observers
are a modifier of their own now. The Workspaces toolbar items are Equatable
views from plain values (filter entries, target, recents, creation options),
so the re-host SwiftUI does on any preference change skips their menus (the
menus' symbol lookups were about 1 s of every 20). Row list modifiers
(insets, background, separator, swipe actions) live in the row view, and each
Mac's display name is worked out once per pass.

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

* cmux-next-ios live: red test, a repeated terminal title is not activity

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

* cmux-next-ios list rig: shot waits for the run's hold line, ARGS for launch arguments; LIST_WRAP keeps the argument domain

LIST_WRAP replaced the argument domain, which dropped -cmuxMobile.setupComplete
(the app opened on Choose a space); it now merges into it.

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

* cmux-next-ios chat: transcript as a collection view of reused rows; streamed text once per frame

The agent chat transcript was one SwiftUI tree for every turn in one hosting
controller: every row stayed alive, each streamed token re-ran the whole
transcript's bodies (turn.rows, pending scans, a full Myers diff of every edit
via changeSet and the snippet's unifiedPreview, a full markdown re-parse), and
opening 200 turns took 46 s and 1.25 GB.

- ChatTranscriptList: UICollectionView with a diffable data source of stable row
  ids (session line, prompt bubble, one row per answer block, tool/card rows,
  footer), cells via UIHostingConfiguration of the existing row views, heights
  cached per (row id, revision, width) in ChatTranscriptLayout (prefix sums,
  binary search), and only changed rows reconfigured. ChatKeyboardDock keeps
  the keyboard docking, follow-bottom, jump button, interactive dismissal and
  composer-height insets on it; a reader scrolled up keeps their row on inserts.
- ChatTranscriptBuilder: rows memoized per turn (rebuilt only when the turn or
  its context changes); answers parsed by MarkdownStreamParse, which re-parses
  only the unfinished tail (equal to a full parse, tested).
- AgentChatStore: text chunks coalesce to one store change per display frame
  (other events flush them first, so order holds); chunk appends are in place;
  hasTurns/isRunning/pendingPermission/pendingQuestion/runningPlanEntries/
  firstPrompt/hasAgentEdits are stored and written only when they change, so
  the composer, toolbar and empty state no longer redraw per token.
- ChatDiffCache (per store): each edit's line diff and snippet preview lines
  computed once, off the main thread; the edited-files card and Changes reuse
  them. Snippet preview lines come from a linear pass (unifiedPreview built
  every hunk header with a backward search: seconds on a 2400-line file).
- The tab grid card (AgentChatPreview) draws the memoized tail rows that can
  show instead of laying out the whole newest answer every frame.
- Shimmers (WorkingLabel, Chief status text and typing dots) are repeating
  animations instead of TimelineView(.animation) bodies per frame; attachment
  thumbnails decode off main; the live call captions no longer run an
  animated scrollTo per token; the subagent transcript is lazy.
- Disclosure state (tool rows, groups, thoughts, long diffs) lives on the
  surface by row key, so reused cells keep it.
- Rig: LIST_SEND=1 sends a prompt through the composer; LIST_OPEN_DELAY.

Tests: SmoothChatTests (coalescing, order, derived state, per-turn memo,
stable ids, incremental parse equality, one diff per edit) and
EditPreviewTests, run on aws-m4pro-6.

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

* CmuxTabsKit: card residency instead of a scroll band; overviewIdleChanged

The first cull rebuilt the grid's body each time its band moved a row, so
every built card re-ran and new cards were built in scroll frames (rig:
30 tabs scrolled worse than with every card built). Residency replaces it:

- CardResidency: one observed slot per card, so building or releasing a
  card re-runs that card only; the scroll offset comes in from the scroll
  geometry callback and never re-runs the grid's body. Cards on screen
  are built at once while the grid shows (a zoom or a fling never shows a
  blank card); cards within 3 screens are built ahead one per frame while
  the grid rests; cards past 4 screens are released. A hidden grid builds
  only its on-screen cards. ResidentCard makes its card in its own body,
  so an unbuilt card costs the grid's body nothing (a built value per card
  was made for all 80 cards on every zoom frame).
- TabsDataSource.overviewIdleChanged(_:) (additive, default no-op): the
  grid is settled, not scrolling and has nothing left to build, the time
  for a source to draw costly cards. README documents it and residency.
- TabsScreen's overview is its own property (the body no longer
  type-checks in time with it inline).

Tests: CardResidencyPlanTests (80 tabs keep a bounded set with every
on-screen card built, scrolling back keeps built cards, on-screen first
then nearest); swift test on mini-cmuxs-mac-mini-3: 62 tests passed.

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

* cmux-next iOS tabs: agent cards as pictures drawn while the grid rests

An agent card's preview (a page of transcript: markdown, edits,
attachments) took about 45 ms to build, so a card that scrolled into view
missed frames. Terminal and browser cards are already pictures of their
pages; agent cards become pictures too:

- AgentCardRasterizer draws a card's preview with ImageRenderer at the
  card's pixel resolution, one per frame, only while the grid rests
  (overviewIdleChanged): built cards first, then the agent cards nearest
  the active tab up to 30 pictures (about 48 MB at most). A running turn
  is drawn live (its working label animates).
- AgentTabCard shows the picture while its key (turns, color scheme, page
  size, insets, resolution) matches, else the live preview.
- DEBUG: CMUX_MOBILE_LIST_PLAIN_CARDS=kind,... draws those kinds' cards as
  a plain fill (the rig's per-kind card cost).

Tests: WorkspaceTabsSourceListTests agentCardPictureKey and
cardScaleFollowsTheKitsCard; with the earlier list tests, 9 passed in the
iOS simulator on mini-cmuxs-mac-mini-3 (xcodebuild test, eager module
emission off: with it on, swift-frontend crashes emitting the untouched
CmuxMobileTerminal module).

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

* cmux-next-ios: a repeated terminal title is not activity; list-row traits stay visible to List

WorkspaceSync stamped activity and re-listed on every title-changed event,
including a shell setting the same title again, so each one republished every
workspace to the store. It keeps each surface's last title per connection and
drops repeats (TitleActivityTests, red before this commit).

The list-row traits (insets, row background, separator guide, swipe actions)
move back outside the Equatable row views: set inside them, List did not see
them and the rows took the default insets (base vs head screenshots now match
pixel for pixel, apart from relative times and animation phase).

Tests (mini-cmux7s): 54 passed in 15 suites, including TitleActivityTests.

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

* cmux-next-ios list rig: the open window waits one beat so a stalled open's long frame is logged

A main thread blocked past the 2.5 s open window resumed in the rig's own task first
and closed the window before the display link logged that frame, so a stalled open
read as an empty window (base chat-40 r2 logged 0 ms).

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

* CmuxTabsKit: the grid is idle only after its offset is still for 6 frames

overviewIdleChanged used the scroll phase, which does not report an
offset set from code (the list rig's scroll, a programmatic reveal), so a
source drew costly cards in scroll frames (rig, 30 tabs with a streaming
chat: 135-214 ms/s of hitches, main CPU 750-920 ms/s). CardResidency now
counts frames without an offset change (FrameClock, 6 frames) and reports
busy while moving or building ahead; the grid is idle only when settled,
not scrolling and not busy. Rig after: 0 ms/s hitches, CPU 160 ms/s.

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

* CmuxTabsKit: observed residency busy state, idle reported from the first frame

Review findings: TabOverview set CardResidency.onBusyChanged to a closure
that captured the view (and through it the source), held by the residency
it owned; and the callback was set after the first update, so a grid
shown from its first frame never reported idle. CardResidency is now
@Observable with an observed busy flag that the grid reads in its idle
expression (onChange, initial: true); the callback is gone.

Tests: swift test on mini-cmuxs-mac-mini-3, 62 passed.

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

* cmux-next iOS tabs: review fixes (frozen titles, workspace identity, pictures, rig)

- A chat store or local browser model made after its tab was built (in a
  page or card body, or a phone-made browser tab) rebuilds that tab once,
  after the update: agent titles and browser titles/progress no longer
  freeze. The phone's new browser tab gets its model before the list.
- Pages, cards and accessories read listedWorkspace, which observes the
  workspace's identity (Mac, folder, title, branch), written only when it
  changes: a replaced workspace reaches them, an activity publish does not.
  A vanished workspace clears the per-tab entries.
- Agent card pictures: asked for again when evicted; turns with
  attachments are drawn live (their thumbnails load in a .task, which a
  picture never runs).
- Terminal cards: a host made in card(for:in:) asks for the Mac's screen
  after the update (a cooled or swapped host no longer waits for the next
  grid opening).
- List rig: the tabs case scrolls the full-screen grid, not the active
  chat shown live on its card (one run scrolled the transcript for 180 s).

Tests: WorkspaceTabsSourceListTests chatStoreAppearingRebuildsItsTab and
workspaceIdentityChangeNotifiesBodies; 11 passed in the iOS simulator on
mini-cmuxs-mac-mini-3.

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

* cmux-next-ios workspaces: red tests for the UIKit list's apply plan and quiet unchanged publishes

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

* CmuxTabsKit: idle computed inside CardResidency, not observed by the grid

The idle state (settled, visible, not scrolling, not moving, nothing to
build) was an observed property the grid's body read, so a scroll that
started or stopped and each build-ahead step re-ran the grid's body. It is
now computed in CardResidency (plain class) and sent to the source through
a sink that holds it weakly; a sink installed after the first update hears
the current state. The scroll phase goes to the residency directly.

Tests: swift test on mini-cmuxs-mac-mini-3, 62 passed.

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

* cmux-next iOS tabs: hold title rebuilds while the grid moves; draw cards with inline images

- A chat store made while the grid scrolls (cards built as they near the
  screen) held its tab's title rebuild until the grid rests; each rebuild
  republished the list and re-ran the grid's body in a scroll frame.
- The picture exclusion for attachments (review fix) made every mock
  agent card live; only an image whose bytes still load (a .task) keeps
  its card live now. Rig, 80 tabs: 362-394 ms/s of hitches after the
  review fixes, 1.8 ms/s with this.

Tests: WorkspaceTabsSourceListTests titleChangesWaitForTheGridToRest and
agentCardPictureKey (inline and loading images); 12 passed in the iOS
simulator on mini-cmuxs-mac-mini-3.

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

* cmux-next-ios workspaces: the list is a UIKit collection with a diffable source

WorkspacesList's SwiftUI List is now WorkspacesCollection: a plain
UICollectionLayoutListConfiguration list, a UICollectionViewDiffableDataSource
<String, String> keyed by the stable section and workspace ids, and each item's
SwiftUI content in a UIHostingConfiguration (rows: WorkspaceListRow with value
inputs, in a registration of their own so a reused cell keeps its graph).

- Updates coalesce to one apply per run-loop turn (ListApplyPlan): a snapshot
  only when the order or membership changed, animated only when rows came or
  went or the change was the person's (and never with Reduce Motion); changed
  rows on screen take their new value in place, others when they come on screen.
- Heights are cached per row shape (two single-line texts below the
  accessibility sizes) or per value, width and text size.
- Long-press menus are UIKit menus built when one opens
  (contextMenuConfigurationForItemsAt); trailing swipe actions come from
  trailingSwipeActionsConfigurationProvider; no leading swipes (the drawer).
- Folds, Needs You, pins, groups, pull-down search, refresh, the press fill,
  accessibility actions and the close popover's FrameProbe anchor keep working.
- AppStore.workspaces notifies nobody when set to an equal value.

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

* cmux-next-ios list rig: tabs-push window around opening the workspace

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

* cmux-next-ios chat: native transcript rows laid out off main; SwiftUI only for rare cards

Round 1 left the transcript at 12-13 ms main-thread CPU p99 per frame while scrolling
and 11-15 ms/s of hitches while an answer streams: every row was a SwiftUI cell that
UIKit sized (systemLayoutSizeFitting) as it scrolled in or grew.

- ChatNativeLayout / ChatNativeWidgets: the rows a transcript is made of (markdown
  blocks: paragraphs, headings, lists, quotes, rules, code cards, tables; the session
  line, the prompt bubble, finished tool rows and groups, closed thoughts, diff
  snippets, the edited-files card, the answer action row) as frames and attributed
  text measured once per (row revision, expanded state, width, text size, code wrap).
  The look follows the SwiftUI views they replace.
- ChatNativeLayouts: layouts made off the main thread in batches (prefetch through
  UICollectionViewDataSourcePrefetching, and every row still on an estimate), kept per
  row; the newest screen on open and the streaming answer's changed block are laid out
  on the spot, so a token re-measures one block, not the answer.
- ChatNativeRowCell: UIKit views reused per kind (one reuse pool per row kind); its
  height comes with the layout, so UIKit never sizes it. Links, Copy and Share of the
  message, the code card's copy and Show all, tables, the prompt's menu and Select
  Text, tool menus, opening diffs and files, viewed marks, the action row and its
  More menu all work as before. Open tool rows, groups and thoughts, running rows,
  math and accessibility-size action rows stay SwiftUI, in per-kind pools updated in
  place.
- DiffViewedStore.isViewed(key:) so the card's viewed marks are not rehashed per draw.
- DEBUG: CMUX_MOBILE_CHAT_ROWCOST=1 logs main-thread time per row kind (the rig greps it).

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

* cmux-next-ios chat: native row labels draw asynchronously at commit, as the Feed's do

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

* cmux-next-ios workspaces: O(changed rows) live applies on the UIKit list

- A live reorder applies as the moved items only (moveItem on the shown
  snapshot, found as the items outside the longest kept run); a new full
  snapshot only for bigger changes. Changed rows on screen take their value in
  place.
- Cells are plain UICollectionViewCells with their own hairline: a list cell
  asked its hosting view for the separator inset on every layout, a SwiftUI
  layout pass per cell (a third of the main thread while scrolling).
- Heights by row shape (two single-line texts below the accessibility sizes).
- The inbox sorts on keys read once per row; the inbox memo keys on
  AppStore.workspacesRevision instead of comparing every workspace; each Mac's
  manage / arrange / group rights are worked out once per pass
  (WorkspaceCreator.rights(onMac:)); New Workspace's recent folders are one pass
  over the workspaces, cached per revision.

Tests (mini-cmux7s, swift test --filter): 46 passed in 13 suites, including
WorkspacesListPlanTests, WorkspacePublishTests and WorkspaceInboxSortTests.
WorkspacesListMoveTests are iOS-only (they build with the app, not run by swift
test on macOS).

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

* cmux-next-ios chat: native table rows and prompt bubbles at SwiftUI's heights

Parity stills against the original SwiftUI transcript (cec0c80b8ed7) showed data rows of
a table 2.7 pt taller (an SF Mono run raised its line) and two-line prompt bubbles 3 pt
taller (TextKit counts line spacing under the last line). Both now take n lines of the
base font's height plus the spacing between lines, from a TextKit line count.

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

* cmux-next iOS tabs: still agent cards, held grid transcript, lazy browser models

Round 2 of the tabs lane, on the integration head.

- Agent cards are stills: a card's copy of its chat changes only when the
  source catches it up (once a second while the grid rests, one card per
  frame), never per streamed token. A card shows its last picture while a
  newer one waits (as a terminal's last frame does), so building a card
  never lays out a page of transcript; a running turn is drawn too.
- The active chat on the grid is its live page (no static card, nothing
  rebuilt at settle), and that page holds its transcript while the grid
  shows (AgentChatSurface.holdsTranscript): one reload a second while the
  grid rests, and one on leaving it. ChatTranscriptList skips reloads
  while held and applies them on refreshHeldTranscript().
- Opening a workspace makes no web view: BrowserTabModel is made when its
  page first shows; a browser card uses an existing model or the streamed
  one, never creates one.
- DEBUG: CMUX_MOBILE_LIST_NO_CARD_PICTURES=1 turns agent card pictures off.

Tests (iOS simulator, mini-cmuxs-mac-mini-3): WorkspaceTabsSourceList and
Update suites, 15 passed (new: gridHoldsTheActiveTranscript,
cardFeedCopiesOnlyOnCatchUp, browserModelsAreMadeOnFirstPage); the
CmuxMobileAgentChat suites, 156, pass but PhoneFullAccessModeRuleTests,
which fails the same way on the integration head (it looks for the repo
root above the synced tree).

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

* cmux-next-ios chat: reload, not reconfigure, a row whose reuse pool changed

A streaming block keeps its row id while its kind can change (a paragraph that turns
into a list moves from the text pool to the list pool), and UIKit refuses a reconfigure
that dequeues from another registration (NSInternalInconsistencyException, seen in a
LIST_STREAM run). Each row's pool is recorded when its cell is configured; a change of
pool reloads the row.

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

* cmux-next-ios workspaces: separator ends where the List's did; a UI test for the list's touches

- The row hairline ends 16 pt from the trailing edge, as the plain List's
  separator did (measured at 3x: 1062 px from x = 96 px, 48 px short of the
  edge); its thickness (1 pt, 3 px) and color (.separator, #E8E8E8 in light)
  already matched.
- WorkspacesListUITests (XCUITest, real touches on mock data): a row's long-press
  menu (Rename from it), the trailing swipe (Rename, then Close, which asks in its
  popover), an offline Mac's fold line, the pull-down search, and a tap that opens
  the workspace. Passed on mini-cmux7s (xcodebuild test, iOS 26.5 sim).

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

* cmux-next-ios: review fixes for Feed read marks, artifact thumbnails, Home typing dots

- FeedReadMarks.load never waits on the disk queue: the newest marks (or a removal)
  are kept in memory until written, and an older queued write is skipped.
- Artifact thumbnails: an "Edited" detail is part of the cache key, so a file the
  agent changed again gets a new thumbnail; HTML snapshots use the display's scale
  (off screen there is no window to read it from).
- Home typing dots: still dots under Reduce Motion, restarted when it changes.

Tests (aws-m4pro-5): FeedStoreScale|FeedReadMarks|FeedStoreChurn|Persistence|
AccountSignOut|DiffPrepare|MacHome|HomeListMemo: 55 tests in 12 suites passed.

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

* CmuxTabsKit: unbuilt grid cards stay in the accessibility tree

Review round 3 (low): cards outside the residency band were absent, so a
VoiceOver swipe through the grid stopped at the last built card. An unbuilt
card is now a bare accessibility element in its slot (no pixels, no
touches), labelled as its built card ("<title>, tab" or "<title>,
Pinned") with Open as its activation; scrolling to it builds the card.

Not covered by a unit test (a view's accessibility tree); swift test on the
kit still passes.

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

* cmux-next iOS tabs: an older card picture never hides a loading image

Review round 3 (high): AgentTabCard showed any picture of the same layout,
and want() ignores a key that cannot be drawn (a turn with an image whose
bytes are still loading, as Mac-recorded images arrive), so the card
froze on its pre-image picture and the live preview never came back.
AgentCardRasterKey.mayShow: a picture shows only when drawn for this key,
or, while a newer one waits, when this key is drawable and the layout
matches; a key that cannot be drawn drops the old picture and the card is
live until it can be drawn.

Test: WorkspaceTabsSourceListTests.olderPictureNeverHidesALoadingImage.

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

* cmux-next-ios chat: review round 3 fixes (accessibility, AX sizes, shimmer, layout re-entry, borders, revisions)

- Native rows are accessible without VoiceOver's help: every tap area is a button element
  with a label (Copy, Share, Retry, a tool row, Open diff with the file and counts, Review,
  each edited file with its viewed state, Show more); text runs carry explicit labels, links
  are custom actions ("Open <text>") on their text, list markers are not read alone; the
  code card and table expose their own elements. DEBUG CMUX_MOBILE_CHAT_AXLOG=1 logs each
  kind's tree for the rig.
- Every kind is SwiftUI at accessibility text sizes (ChatNativeEligibility, portable and
  tested), as the native rows' contract says; ChatNativeLayout.make refuses AX sizes too.
- WorkingLabel's shimmer and Chief's shimmer and typing dots run on repeating keyframe
  animators, which start from their first frame on every appearance; the repeatForever
  started in onAppear could be cancelled in the same update and freeze.
- A width change no longer reloads from inside the collection view's layoutSubviews (a
  snapshot apply re-entering its layout): the controller asks from viewDidLayoutSubviews.
- Card borders re-resolve their color when light and dark switch.
- Row revisions come from one counter that only rises (ChatRowRevisions), so a row that
  leaves and returns (a turn's working label) never reuses an old height; measured heights
  are pruned with the rows.

Tests: ChatNativeEligibilityTests (every kind SwiftUI at AX sizes; open rows and math stay
SwiftUI; a returning row gets a new revision) pass on aws-m4pro-6. They are new types, so
there is no red run on the old code.

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

* cmux-next-ios list rig: final before/after script

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

* cmux-next-ios list rig: sync the specs the package tests read (schemas/, cmux-tui/spec)

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

* cmux-next-ios artifacts grid: no web snapshot under a moving grid; one shared make per thumbnail

The final before/after run found the artifacts grid scrolling worse than before
(44-48 ms/s hitches vs 2 ms/s): late frames every ~33 frames with the main thread
idle. Each was a WKWebView loading and painting an HTML tile's snapshot under the
moving grid; with the cache now keyed by id, the cancelled snapshots of tiles
scrolled past restarted on every pass.

- HTMLSnapshotter starts no snapshot while the grid moves (ArtifactGridMotion,
  rest after ~6 still frames), then serves the newest asks first (tiles on screen).
- ArtifactMemo runs one make per key shared by every tile asking, and cancels it
  only when the last of them scrolls away (nothing recorded, so it retries).
- Picture thumbnails are prepared for display off the main thread.
- Rig: LIST_HOLD_Y rests a still at a fraction of the list.

Rig (aws-m4pro-6, artifacts LIST_COUNT=40): head 0.0 ms/s hitch, max 16.7 ms in
3 of 3 runs (was 44.5/47.6 ms/s); base 1.6/1.7 ms/s. Stills at rest show the same
HTML, image and PDF thumbnails as base. Tests: full CmuxMobileKit suite on
aws-m4pro-5 (memoSharesOneMakeAndStopsItWhenNoTileWaits, 5 runs stable).

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

* cmux-next-ios chat: warm each native row kind's first draw after the screen appears

The final before/after run had one 250-280 ms frame early in a chat's first
scroll, with the main thread idle (~6 ms): the first cell of a row kind
building its labels' GPU text path and glyph caches. One off-screen cell per
kind is now configured and drawn into a small context once the transcript
screen has appeared (viewDidAppear), one per run-loop turn and never while the
list moves, as the Feed warms its rows.

Rig (aws-m4pro-6): chat-40 scroll 0.0 ms/s, max 16.7 ms in 5 of 5 runs (was one
257-282 ms frame in 2 of 4); chat-40 streaming 0.0 ms/s in 2 of 2. The open
window's worst frame is 103-105 ms in 4 of 5 runs (199 ms once), with nothing on
screen moving, against 51-115 ms before the warm-up.

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

* test(ios): list store changes before reading the tabs source in four base tests

WorkspaceTabsSource (#27087) lists store changes on the next main-queue turn and reads each
tab's listed copy. Four tests from base (#27084, #27322) changed the store and read the source in
the same turn, so they saw the old tabs. They now call `source.refreshTabs()` first, as #27087's
own WorkspaceTabsSourceListTests do: restartShellOnlyOnAnEndedShell, tabGroupsFromTheTabMenu,
tabGroupPageActions and aBlankBrowserTabHasNoAddress (fleet kit run at 3f8df7b2, step
48cd3bbd368434dadcb35501; none fail on base c00c3219).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-89c8bbec072d313a51cd

* chore(ios): drop an editor temp file the merge staged by mistake

WorkspaceTabsSource.swift.tmp.729016.19be7d478e30 was an edit hook's temp copy left in the
worktree while the merge's conflicts were resolved, and `git add` of the kit folder took it.
It is no source file (SwiftPM builds only .swift); nothing references it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-89c8bbec072d313a51cd

* fix(ios): review P2s: a live reorder moves rows in place; opening a diff never diffs on the main thread

Two findings of the independent slice reviews of #27664:

- Workspaces list: `WorkspaceInbox.Memo.workspaceIDsKey` changed on a reorder too, and the
  list animates a full snapshot when that key changes, so a live reorder (a row going to the
  top while a Mac publishes) skipped the moved-rows path it was meant to take. The key now
  changes only when the set of workspace ids does. Test: memoBuildsOncePerChangeOfItsInputs
  reorders the rows and expects the key unchanged.
- Agent chat: tapping an edit snippet (or its Open Diff menu row) before the turn's diff landed
  ran `changeSetNow`, the whole Myers diff, on the main thread (hundreds of ms on a large
  rewrite). The tap now awaits `ChatDiffCache.changeSetReady`: the cached diff, else the one
  computing, else one computed off the main thread. Test:
  openingChangesBeforeTheDiffLandsNeverDiffsOnTheMainThread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-89c8bbec072d313a51cd

* fix(ios): review P2s in the native chat rows and the artifacts grid

Findings of the independent slice reviews of #27664 (all in #27087's native lists):

- A markdown table's links did nothing when tapped and had no VoiceOver action: the cell
  hit-tested only its own text runs. `ChatTableView.link(at:)` finds the link under a tap, and
  each cell label gets the same "Open <link>" actions as the row's text.
- Long-pressing the second file of a multi-file edit snippet opened, copied or showed the
  first file. The row's menu now gets the file of the card under the press.
- With "Wrap code and diff lines" on, the native snippet still drew its diff lines unwrapped.
  Finished edit snippets now draw as the SwiftUI snippet (which wraps) while the setting is on.
- VoiceOver skipped a native snippet's diff lines. Each line scroller is an element, read with
  "Added" / "Removed" for its markers.
- ArtifactMemo: a cancelled make that ended late decremented the waiters of the make that
  replaced it, so cancelling one of two tiles stopped the other's thumbnail. Waiters are now
  per make (a generation), and each caller leaves once. Test:
  aLateCancelledMakeNeverStopsItsReplacement.
- HTML thumbnails: a page whose load never finished held one of the two snapshot slots until
  it did, blocking every later HTML thumbnail. A load now ends after 5 s or when its tile
  goes away (stopLoading), and gives its slot back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-89c8bbec072d313a51cd

* test(ios): the main-thread diff test belongs in SmoothChatTests (its store fixture and main actor)

openingChangesBeforeTheDiffLandsNeverDiffsOnTheMainThread was added after the closing brace of
SmoothChatTests, into EditPreviewTests, which has neither Silent nor Self.turn and is not
main-actor isolated: the kit tests did not compile (fleet step eb392c6e367a7737771570e1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-89c8bbec072d313a51cd

* test(ios): regressions for review findings at a9366668

Find in Chat's current match reads as selected to VoiceOver; an edited-files
card's viewed key follows the file's content; a row queued for an off-main
layout before an environment change is laid out for the new one; a read-marks
save queued after a removal lands. Each fails at a93666681ed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* fix(ios): review P1/P2s after the base merge

- Open on iPhone / Mirror from Mac rebuild the tab in the same turn, so the
  toolbar follows the page the person chose (NativeBrowserTabTests failed at
  a93666681ed: the tab list is a listed copy since #27087).
- Find in Chat: the current match's row reads as selected to VoiceOver, as
  base's SwiftUI find marks did (native elements and hosted rows).
- Edited-files cards: the viewed key is DiffViewedStore.key (the content hash is
  computed once per content); the cache keyed on counts could show a changed
  diff as viewed.
- Native row layouts: a row queued before an environment change (rotation,
  Dynamic Type, code wrap) or for an older revision is queued again, not laid
  out on the main thread as it scrolls in.
- Demo open-changes commands await the off-main diff; changeSetNow is gone, so
  no path diffs on the main thread.
- Artifacts: a reused code preview never shows the previous file's lines; an
  HTML thumbnail waiter cancelled while the grid moves leaves the queue at once.
- Feed read marks: queued file operations are numbered, so a write queued before
  a removal never takes the marks of a save queued after it.
- listperf rig: seed-xcf refuses to copy a directory onto itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* test(ios): a native heading reads as a heading; a tab moving screens updates its card chip

Both fail at 1ad5d8f6377 (review findings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* fix(ios): review P2s at 1ad5d8f6 (find reveal, native block parity, screen chips)

- Find in Chat lays the matched row out before scrolling to it: a row far up
  may still wait for its off-main layout, and the reveal used its estimate.
- Native answer rows: a markdown heading carries VoiceOver's heading trait;
  block and code menus offer Select Text (the SwiftUI blocks' text selection).
- Grid cards: the observed workspace identity includes its screens, so a tab
  moving between the Mac's screens updates its card's screen chip.
- Tests: the thumbnail-memo waits record an issue when they run out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* fix(ios): a replaced artifact load writes nothing; the chat stress rig's later passes go down

ArtifactPreview's load task checks cancellation after each wait, so a load
replaced by a newer artifact or a retry never overwrites its preview (no unit
seam: a SwiftUI view task). ListStressRig's chat passes after the first start at
the top and now scroll down (they reached the top at once and measured nothing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* test(ios): the find-reveal layout test picks any native row still waiting for its layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* test(ios): a closed chat's transcript releases its store and surface

Regression test for a review finding: ChatTranscriptList's pending
observation held the store and surface strongly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* test(ios): an unbuilt tab card offers Close to VoiceOver

Regression test for a review finding: CardStandIn exposed only Open,
while a built card offers Open and Close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* fix(ios): an unbuilt tab card offers Close; the transcript's observation holds its store weakly

CardStandIn now offers the built card's actions (Open, and Close for a
regular card). ChatTranscriptList's observation callback captures the
store and surface weakly, so a closed chat is released without waiting
for one more change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* test(ios): the running-program close test waits for the Mac's answer

closingATabWithARunningProgramAsks read the question's message as soon
as it showed. When the simulator is busy the 250 ms quick answer is
missed, the question shows as pending first, and the answer fills it in
a moment later (the designed path); the test failed on that timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* test(ios): a minified diff line is prepared off main; the tab grid builds ahead only at rest

Regression tests for two review findings: DiffRenderer prepared any file
of up to 300 lines on the main thread whatever its length in text, and
CardResidency kept building off-screen cards one per frame while the
grid scrolled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* fix(ios): prepare a long-text diff off main; build tab cards ahead only while the grid rests

DiffRenderer prepares a file in place only when it is under 300 lines
and 24,000 characters, so a minified line no longer blocks the main
thread. CardResidency builds ahead of the screen only while the grid is
not scrolled by a finger or from code, and resumes when it is still.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-7aa94f16aeac121e03bf

* test(ios): base's transcript tests build the list's controller

JumpReduceMotionTests (#27776) and SlashSuggestionsReachTests (#27767)
built ChatTranscriptController with hosted content and looked for the old
ChatTranscriptScrollView; #27087's list takes the store and draws rows in
ChatTranscriptCollectionView. The jump test seeds a long store instead of a
9000 pt view; the list's jump never hides the transcript, so the hidden
frame check in the without-Reduce-Motion oracle is dropped (the cut and
spring checks stay).

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

* cmux-next-ios tabs: a store change reaches the stored tab list in the same turn

The stored kit tab list was rebuilt on the next main-actor turn after a store
change, so code that changed the store and read the tabs (or a tab's entry) in
the same turn saw the old list (CI's WorkspaceTabsSourceTests on #27087:
restartShellOnlyOnAnEndedShell, tabGroupsFromTheTabMenu,
aBlankBrowserTabHasNoAddress, closingATabWithARunningProgramAsks). 3981ade33a1d
made those tests call refreshTabs() by hand; app code that does the same
(change a tab, then read the list) still read the old one. A change now marks
the list (or the one tab) stale synchronously and the next read rebuilds it;
otherwise the rebuild still runs on the next turn. Still O(changed tabs).
The tests' manual refreshTabs() calls are removed: they pass without them.

From #27087 (b2e7a1ad56a9), onto #27664.

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

* test(ios): iOS test build and the long-jump landing check

- DiffRenderer.preparesInPlace and its limits are nonisolated: a pure size
  check, called by DiffInlineBuildTests from a nonisolated test, which did not
  compile on the iOS simulator (the macOS run skips the iOS-only suite).
- withoutReduceMotionTheLongJumpStillAnimates: the jump re-targets after rows
  measured by its cut move the newest turn's bottom (5 pt here); the landing
  checks allow that.

iOS simulator (aws-m4pro-6, iOS 26.5): CmuxMobileAgentChatTests 249 passed.

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

* test(ios): a probed localhost port is returned only once its listener closed

NativeBrowserTabTests.freePort() bound a port with a probe forwarder, stopped it
and returned at once. The probe's listener holds the address until it reports
cancelled, and the forwarder under test is another instance, so it did not wait
for it: its bind lost the port ("Another app on this iPhone uses port ...") and
aMacThatForwardsOpensItsLocalhostPageOnThePhone failed 4 of 4 on a busy host.
The probe now waits for its own closing (LocalhostForwarder.closedEarlier, made
public: a stopped port is free for another listener only once it returns).

iOS simulator (aws-m4pro-6): NativeBrowserTabTests 13 passed, 5 of 5 runs.

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

* test(ios): CardViewedKeyTests and CodePreviewLinesTests are iOS-only

Both test iOS-only types (ChatNativeRowActions, CodeView) but sat outside the
file's #if os(iOS), so CmuxMobileAgentChatTests did not compile for CI's macOS
swift test (the iOS simulator run passed). Moved into the iOS block next to
DiffInlineBuildTests.

macOS swift test (aws-m4pro-5): compiles; 1,566 tests, 4 failures that also fail
on feat-cmux-next on that host (PreviewInsufficientSpace over SSH).

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

* test(cmux-next-ios): regressions for the #27664 review blockers and P1s (red)

Tests first, against de6d2493 behavior, with test-only seams (no behavior change):
- NativeRowFoldTests: reopened row takes its open height (blocker 1), a native line
  holds its place through open and close while following (blocker 2), a text size
  switch moves rows between native and SwiftUI cells (P1).
- AgentCardPictureTests: a card picture shows an image attachment (blocker 3), a card
  drawn before its diff landed is drawn again (blocker 4), a card takes the grid's
  page size before any page shows (P1).
- TitleActivityTests.aCoveredTitleEventDoesNotHideTheNextRealOne (P1).

Seams: ChatTranscriptController.transcriptList/isHoldingFold, ChatTranscriptList
cell(for:)/visibleRowIDs/row(_:), ChatNativeRowCell.perform internal,
AgentCardRasterKey.current (the one key builder for built and ahead cards),
WorkspaceSync.handle(_:sequence:) and an internal snapshotSequence.

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

* fix(cmux-next-ios): transcript rows re-measure on a disclosure change, native lines hold their place, text size switch swaps cell types

Review of #27664 at de6d2493, blockers 1 and 2 and the ChatTranscriptList P1:

1. ChatRowCell.preferredLayoutAttributesFitting reused the cached height while the row's
   revision matched, and a disclosure is not part of the revision: a reopened or reclosed
   thought/tool row kept its other state's height. The layout's measured heights are now
   keyed on the row's disclosure states too (ChatTranscriptLayout.Item.fold,
   ChatRowLookup.fold), and a cell's known height stands in only for the first sizing pass
   after a configure (a later pass is the content changing size, which is measured).
2. Native rows toggled disclosures with ChatMotion.set and skipped the transcript's hold,
   so a chat following its newest row re-pinned and the tapped line jumped. Native taps
   now go through toggleDisclosure(context.foldHold) like the SwiftUI rows (ChatMotion is
   gone), and the hold lets go only after the list has applied the disclosure change
   (ChatTranscriptController.endFoldHold defers to the next main turn while
   ChatTranscriptList.disclosuresPending, then reloadIfDisclosuresChanged): a native tap
   animates nothing in SwiftUI, so its completion could come before the row's reload.
P1. environmentChanged chose rows to reload after installing the new environment, so at an
   accessibility size no row counted as native and visible native cells stayed native. It
   now takes the rows native before the switch plus every shown row whose cell type the new
   environment changes.

Red at 1745059a9969, green here (/tmp/smooth27664/ios.sh (xcodebuild test, CmuxMobileKit-Package, iOS sim on aws-m4pro-6)):
  -only-testing:CmuxMobileAgentChatTests/NativeRowFoldTests
  red:   aReopenedRowTakesItsOpenHeight (closed again 294 pt taller), aNativeLineHoldsItsPlace...
         (line moved while opening; still following), aTextSizeSwitchMoves... (native cells at AX size)
  green: 3/3 passed; JumpReduceMotionTests 2/2 passed

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

* fix(cmux-next-ios): agent card pictures wait for thumbnails and diffs, and take the grid's page size

Review of #27664 at de6d2493, blockers 3 and 4 and the WorkspaceTabsSource P1:

3. Sent image thumbnails were decoded in SentAttachments' .task, which a synchronous
   ImageRenderer (AgentCardRasterizer.render) never runs: tab-card pictures showed the
   placeholder for good. Thumbnails now decode off the main thread into one cache
   (SentAttachmentThumbnails, per image and size); the live row still decodes in its .task
   and the body reads the cache synchronously, so a picture draws the decoded image. The
   card key waits for them (AgentCardRasterKey.pictures from AgentChatPreview.picturesReady,
   which starts the missing decodes and is observed), so the picture is drawn once they land.
4. A card drawn while the diff cache was cold kept the loading snippet: the key now carries
   the chat's ChatDiffCache.version (AgentCardRasterKey.diffs), so the picture is drawn again
   when diffs land (the older picture stands in meanwhile).
P1. The page size came only from WorkspaceTabPage.onAppear: a grid shown first, or rotated
   with the grid up, drew cards at 402x874. WorkspaceTabCard reports the size the kit lays
   it out at (the full page, scaled down) to WorkspaceTabsSource.rememberPageSize, and the
   page size is observed so the cards follow it.
AgentCardRasterKey.current builds the key for built cards and those drawn ahead alike.

Red at 1745059a9969, green here (/tmp/smooth27664/ios.sh (xcodebuild test, CmuxMobileKit-Package, iOS sim on aws-m4pro-6)):
  -only-testing:CmuxMobileWorkspacesTests/AgentCardPictureTests
  red:   aCardPictureShowsTheImageAttachment (8 red pixels), aCardDrawnBeforeItsDiffLandedIsDrawnAgain
         (key unchanged, picture done), aCardTakesTheGridsPageSizeBeforeAnyPageShows (402x874)
  green: 3/3 passed; WorkspaceTabsSourceListTests 14/14 passed
(The test helper now passes the turns to AgentCardRasterKey.current instead of setting them
after it, so the readiness is read for those turns.)

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

* fix(cmux-next-ios): a title event the snapshot covers no longer hides the next real one

Review of #27664 at de6d2493, the WorkspaceSync P1: enqueue recorded a title (and its
activity) before the snapshot-sequence filter dropped the event, so a stale titleChanged
made the next real one with the same text look like a repeat. The filter now runs first.

Red at 1745059a9969, green here (/tmp/smooth27664/ios.sh (xcodebuild test, CmuxMobileKit-Package, iOS sim on aws-m4pro-6)):
  -only-testing:CmuxMobileLiveTests/TitleActivityTests
  red:   aCoveredTitleEventDoesNotHideTheNextRealOne (activity set by the covered event; the real one ignored)
  green: 2/2 passed

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

* feat(CmuxTabsKit): tell a card's content the page size it is laid out at

Additive, kit-only: `EnvironmentValues.tabsKitCardPageSize` is set on grid cards
(TabCard), the zoom's card and the swipe neighbor's card (the full page scaled into the
card), and left nil where a card is laid out at its own size (its long-press menu preview,
CardMenuPreview, and its drag preview). A host can then take the page size from the grid
and never from a card's own geometry (review of #27664 at 2a38f796, item 1).

Tests: CardPageSizeTests (aGridCardIsToldThePageSize, aCardsMenuPreviewIsToldNothing),
run with xcodebuild test -scheme CmuxTabsKit on the iOS simulator on aws-m4pro-6.

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

* test(cmux-next-ios): regressions for the 2a38f796 review (red)

- AgentCardPictureTests.aCardsPreviewLeavesThePageSize: a card laid out at its own size (its
  long-press menu or drag preview) must not set the page size (item 1, P1). The grid-first test
  now hosts the card the way the grid does (tabsKitCardPageSize set).
- SentAttachmentThumbnailsTests.bytesThatDoNotDecodeCountAsReady (item 2) and
  aBurstPastTheBoundSettles (item 3).
- NativeRowFoldTests.theReadersOwnScrollReleasesAHeldLineAtOnce (optional item).
Seams (no behavior change): SentAttachmentThumbnails byteLimit (not enforced yet),
residentBytes, decodes.

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

* fix(cmux-next-ios): agent cards take the page size from the grid, never a card's own size

Review of #27664 at 2a38f796, item 1 (P1, a regression of b55cb9b6): WorkspaceTabCard
reported its own laid-out size to rememberPageSize, and a card is also laid out at its own
size (about 177x249) by its long-press menu preview (CardMenuPreview) and its drag preview.
A long press set the page to that size; the size is observed, so every agent card redrew with
its transcript 177 pt wide (and the wrong bottom inset) until a page appeared. The card now
takes the size TabsKit lays the grid's cards out at (tabsKitCardPageSize, 712fa07c), which
those previews leave nil.

Red at a042a3af431a, green here (/tmp/smooth27664/ios.sh (xcodebuild test, CmuxMobileKit-Package, iOS sim on aws-m4pro-6)):
  -only-testing:CmuxMobileWorkspacesTests/AgentCardPictureTests
  red:   aCardsPreviewLeavesThePageSize (page became the card's size)
  green: 4/4 passed (aCardTakesTheGridsPageSizeBeforeAnyPageShows too); WorkspaceTabsSourceListTests 14/14

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

* fix(cmux-next-ios): the thumbnail cache is bounded by bytes, settles on bad bytes and never re-decodes in a loop

Review of #27664 at 2a38f796, items 2 and 3 (P2):
2. Bytes UIImage(data:) cannot read stored nothing and bumped nothing, so ready() stayed
   false (the card a live preview for good) and every body started the decode again. A
   failed key is recorded and counts as ready (the picture draws the placeholder, as base did).
3. 96 thumbnails at about 1.9 MB each could hold about 180 MB, and an eviction bumped the
   shared revision, so every card re-read ready() and decoded again. The cache now keeps at
   most 32 MB of pixels (least recently used out, cost = pixel bytes). Readiness only goes
   forward: a thumbnail once decoded stays ready after an eviction, and the revision bumps only
   when a key first becomes ready. A picture about to be drawn checks residency
   (AgentChatPreview.picturesResident); an evicted thumbnail is decoded again for that card
   alone, which waits at the end of the rasterizer's queue.

Red at a042a3af431a, green here (/tmp/smooth27664/ios.sh (xcodebuild test, CmuxMobileKit-Package, iOS sim on aws-m4pro-6)):
  -only-testing:CmuxMobileAgentChatTests/SentAttachmentThumbnailsTests
  red:   bytesThatDoNotDecodeCountAsReady (never ready, 29 decodes), aBurstPastTheBoundSettles
         (23,040,000 bytes kept, bound 4,194,304)
  green: 2/2 passed; AgentCardPictureTests 4/4

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

* fix(cmux-next-ios): the reader's own scroll releases a held disclosure line at once

Review of #27664 at 2a38f796 (optional): endFoldHold waits for the list to show a pending
disclosure change before the line lets go; a drag, scrollTranscript (Jump to latest, Find)
or a new session is the reader's intent and now releases it at once (endFoldHold(force:)).

Red at a042a3af431a, green here (/tmp/smooth27664/ios.sh (xcodebuild test, CmuxMobileKit-Package, iOS sim on aws-m4pro-6)):
  -only-testing:CmuxMobileAgentChatTests/NativeRowFoldTests
  red:   theReadersOwnScrollReleasesAHeldLineAtOnce (still holding after scrollTranscript)
  green: 4/4 passed; JumpReduceMotionTests 2/2

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

* fix(cmux-next-ios): a row keeps its height for each disclosure state

The measured-height cache kept one disclosure state per row, so a row closed
again fell back to its estimate until a cell measured it again: a jump below the
row, and on CI's slower simulator NativeRowFoldTests.aReopenedRowTakesItsOpenHeight
still saw the estimate after 30 frames (94 pt for a 32 pt line) at f8ed786eeaff.
Each row now keeps its measured height per state (the newest four), so a close
takes the closed height at once.

Red: CI iOS simulator at f8ed786eeaff (aReopenedRowTakesItsOpenHeight, 94 vs 32).
Green: NativeRowFoldTests 10/10 on aws-m4pro-6; full iOS simulator suite green;
macOS swift test 1,583 with only the 4 host-only PreviewInsufficientSpace failures.

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

* test(cmux-next-ios): a row's height for a disclosure state is the one it settled at (red)

CI at f8ed786e failed NativeRowFoldTests.aReopenedRowTakesItsOpenHeight in the full suite
(closed again 94 pt, closed 32 pt), timing-dependent. A measurement trace of the open,
close, reopen cycle (layout records with fold and revision, cell fits and configures)
shows, on every cycle and also in passing runs: after a disclosure change the list installs
the new state's layout items, and the snapshot apply's layout pass fits the visible cell
while it still draws the old state (`fit measured 326 attrs 32`), records that under the
new state's fold (`rec fold <closed> h 326`), and the reconfigure then hands it to the cell
as its known height (`cfg known 326`, `fit known 326`). Only SwiftUI's own later size
invalidation corrected it (`fit measured 32` -> `rec fold <closed> h 32`); when that
invalidation lands mid-update or late (a loaded runner), the wrong height stays.

aRowsHeightForAStateIsTheOneItSettledAt orders the reload and its layout pass before SwiftUI
updates the row and checks the height kept for the state.

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

* fix(cmux-next-ios): a row's height for a disclosure state is measured from its settled drawing

Root cause of NativeRowFoldTests.aReopenedRowTakesItsOpenHeight failing in CI's full suite at
f8ed786e (closed again 94 pt, closed 32 pt). I traced the open/close cycle (layout records
with revision and fold, cell sizing passes and configures, frame by frame) in the full iOS
suite on aws-m4pro-6, where it fails about 2 runs in 3. The failing run's close of row c-c28:

  reload disclosures 2 shown 1
  fit measured 94.0 attrs 32.0 shown 115/<open>      (apply's pass; cell still configured open)
  inv c-c28 h 94.0 laid 32.0 item 115/<closed>
  cfg c-c28 known nil shown 115/<closed>
  fit measured 94.0 attrs 94.0 shown 115/<closed>     (right after the reconfigure)
  inv c-c28 h 94.0 ... kept for <closed>
  frames 0..29: height 94                              (no later sizing pass)

ChatRowCell sized its hosted view (systemLayoutSizeFitting) before that view had laid out.
UIHostingConfiguration applies a new configuration, and a disclosure change its views read
themselves, in its own layout pass. Sized before it, it reported its old or half-applied
drawing (94 = the line plus part of the closing output). That height was kept as the row's
height for the new state, and the view's later update did not always size the cell again.
The same trace in a deterministic ordering (the list's reload and layout pass before SwiftUI
updates the row) showed the stale height kept for both states on every cycle
(aRowsHeightForAStateIsTheOneItSettledAt, red at b54f0ea2).

Fix:
- ChatRowCell.preferredLayoutAttributesFitting lays its content view out before measuring.
  It measures the drawing the cell will show.
- The cell's sizing pass carries the state the cell was configured with
  (ChatRowAttributes.shown, from ChatRowCell.shown). ChatTranscriptLayout keeps a
  measurement as the row's height for a state only when that is the row's current revision
  and disclosures, and no disclosure change is waiting for the list's reload
  (measuresSettle). A cell configured with the old state (the apply's pass before its
  reconfigure), or a SwiftUI row already drawing a change the list has not reloaded for, is
  laid out as drawn but not kept.
- 638dec40's per-state heights (RowHeights) are kept: a row that returns to a state takes its
  settled height at once.

Red, then green (/tmp/smooth27664/ios.sh: xcodebuild test, iOS 26.5 sim on aws-m4pro-6):
  aRowsHeightForAStateIsTheOneItSettledAt at b54f0ea2, -test-iterations 3: red 0/3
  (open state kept 32 for 326; closed kept 326 for 32).
  Full suite at the previous fix attempt: aReopenedRowTakesItsOpenHeight failed 2 of 3
  (94 vs 32). Here: see the PR comment for 10/10 alone and the full suite 3 in a row.

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

* test(cmux-next-ios): a SwiftUI row measured under the top safe area takes its own height (red)

The root cause of the CI failure of NativeRowFoldTests.aReopenedRowTakesItsOpenHeight at
f8ed786e (closed again 94 pt, closed 32 pt; failing 1 to 2 runs in 5 of the full suite
here, never alone). The extra 62 pt is the window's top safe area. A full-suite trace of a
failing run shows that at the close the row was re-pinned with its top above the screen
(`MARK close line y -44`). In the following pass the hosted cell measured 94 twice. A
settle-check probe that re-measured the content in place at the cell's position measured the
open state 388 (326 + 62) and the closed state 94 (32 + 62), every time the row sat under the
status bar.

aRowMeasuredUnderTheTopSafeAreaTakesItsOwnHeight puts …

This branch was previously deployed

3 inactive deployments
artifacts — b72eafae Deployed Oct 28, 2026 by azooz2003-bit via add the Windows daemon to the tree #73403
sentry-dsyms — b72eafae Deployed Oct 28, 2026 by azooz2003-bit via upload cmux-tui dSYMs to Sentry #73403
ci-routing-agent — b72eafae Deployed Oct 28, 2026 by azooz2003-bit via wait for same-key tree publisher #73403
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants