Repository navigation
cmux NEXT iOS: tab group color, icon, collapse, moves and New Tab in Group; the workspace's screens (switch, new, rename, close) - #27084
Conversation
…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>
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
|
Correctness review (fresh subagent, given the diff at 96a3cdf) and resolutionA fresh review subagent read the diff against
The reviewer also confirmed these are correct: every command and field is on both lane policies (the Mac's |
|
Bead |
|
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, |
|
Review (codex-review-box/gpt-6-astra high, lane-place): SHIP at b72eafa SHIP |
|
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). |
|
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). |
|
Shipped in:
|
…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
* 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>
…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>
…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
…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>
…#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 …
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
daemonlane 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 onorigin/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
TabGroupReorderrule), 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);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
focus-paneon the screen's active pane. The daemon'sselect-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-panemakes 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-groupinto the destination's first screen's default pane (never a chat dock), as the Mac'stabGroup.moveToWorkspace. The slice audit listed it as off the lane;move-tab-groupis on it, so it is done.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.ChatDockRules).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 appfeat-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 step17d3cd96); after is this branch (c03efa5, fleet stepcb5ea816; the later b72eafa changes only the rig script).build: red → purple (0.0 s), iconstar→ none, collapsed true → falset1, t4, t2, t3→t1, t2, t3, t4p2tabs-groups-dest's pane (the source keepst1, t4), then in new workspaceworkspace-5logs; Screen 1 active;logsclosedScreenshots, 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
cb5ea81648b286f7b76219daat 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.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) andWorkspaceTabsSourceTests.tabGroupPageActions/.screensFromThePageMenu.Observed / Not run
Tagged build:
feat-cmux-next-ios-groups-screens-parity, fleet job9fdcfa6c1f83ba024069aac0at 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