Skip to content

cmux-next iOS: every vertical list scrolls at full frame rate - #27087

Closed
azooz2003-bit wants to merge 64 commits into
feat-cmux-nextfrom
feat-cmux-next-ios-smooth-lists
Closed

azooz2003-bit wants to merge 64 commits into
feat-cmux-nextfrom
feat-cmux-next-ios-smooth-lists

Conversation

@azooz2003-bit

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

Copy link
Copy Markdown
Collaborator

Summary

Every vertical list in the cmux-next iOS app now scrolls at full frame rate, idle and while live data changes (agents streaming, a busy Mac publishing workspace updates). Do not merge until it is dogfooded: the owner tries the tagged build first.

What changed, by list:

  • Agent chat transcript (CmuxMobileAgentChat): one UIHostingController holding every turn becomes a UICollectionView of reused rows with a diffable data source of stable row ids.
    • Common rows (markdown blocks, prompt bubble, finished tools, snippets, edited files, action row) are native UIKit, laid out off the main thread once per revision and width; rare cards stay SwiftUI in their own reuse pools.
    • Streamed chunks apply once per display frame; only the unfinished tail block re-parses and re-measures per token.
    • Each edit's diff is computed once, off main (this was the 45-50 s stall when opening a chat with a large file edit).
    • Keyboard docking, follow-bottom, the jump button, links, menus and VoiceOver access to every action are kept; accessibility text sizes fall back to the SwiftUI rows.
  • Workspaces list (CmuxMobileWorkspaces): SwiftUI List becomes a UICollectionView with stable section ids; a live update applies only moved or changed rows. Row menus are built when opened, the shimmer runs on the render server, the inbox is memoized per store revision, and an equal workspaces write no longer notifies.
  • Tab grid (CmuxTabsKit + host): only cards near the screen are built, the hidden grid does no work, agent cards draw as pictures refreshed while the grid rests, and browser models are made on first view. The kit-only commits are proposed to feat-cmux-next-tabs-kit in CmuxTabsKit: build only nearby grid cards; dormant hidden grid #26818.
  • Diff file screen (CmuxMobileDiff): a UICollectionView with rows, highlighting and heights prepared off main per file and options; horizontal panning moves one shared offset with no SwiftUI body work. hunks(context:) is one forward pass; viewed keys are unchanged.
  • Files and artifacts (CmuxMobileArtifacts): thumbnails decode off main, one shared make per artifact, no web snapshot under a moving grid, linear syntax highlighter off main.
  • Home (CmuxMobileChief): Equatable rows, memoized list, menus on demand, typing dots on the render server.
  • Feed store (CmuxMobileFeed, CmuxMobileLive): items indexed by id, read marks written off main, the Mac's reads in one write, linear echo check.

A DEBUG-only rig (ListStressRig, verify/listperf/) seeds any of these lists, scrolls it and records frame intervals, main-thread CPU per frame and memory.

Before and after

60 Hz iPhone 17 Pro simulator, iOS 26.5, aws-m4pro-6, one simulator, base (feat-cmux-next at 4e28d9cf44d9, with the same rig files) and head alternating, 2 runs each. Hitch time is time late per second of scrolling (Apple's hitch ratio; under 5 ms/s is the target).

List Hitch ms/s Worst frame Main CPU ms/s Open, worst frame Memory
Workspaces, 200 rows, 4 updates/s 795–810 → 0–1.1 661–815 → 17–33 ms 942–992 → 178–202 – 76–84 → 56 MB
Workspaces, 500 rows, 8 updates/s 990 → 0.4–6.1 4,608 → 33–39 ms 1,179–1,232 → 231–251 – 92–170 → 59–60 MB
Agent chat, 40 turns 0 → 0 17 → 17 ms 86–92 → 59–74 45,323–45,843 → 103–199 ms 349 → 59 MB
Agent chat, 200 turns 0 → 0 17 → 17 ms 89–94 → 56–70 453–44,313 → 470 ms 1,344 → 68 MB
Agent chat, answer streaming 647–706 → 0 215–238 → 17 ms 1,000 → 128–154 480–40,171 → 56–120 ms 405–475 → 60 MB
Tab grid, 30 tabs, chat streaming 173–396 → 0–1.7 55–68 → 17–27 ms 930–1,014 → 158–163 509–546 → 84 ms 217–222 → 61 MB
Tab grid, 80 tabs 18–75 → 0–5.2 37–54 → 17–35 ms 469–709 → 204–227 679–779 → 79–92 ms 395 → 66 MB
Diff, 2,400-line file 3.5–60 → 0 37 → 17 ms 749–831 → 118–175 40,047–50,366 → 140–231 ms 816 → 76 MB
Diff, horizontal pan 612–639 → 0 56–74 → 17 ms 803–886 → 56–109 – 819 → 66 MB
Files and artifacts grid 2.1–2.3 → 0 33–46 → 17 ms 143–160 → 90–91 39,976–53,465 → 207–213 ms 411 → 78 MB
Home, 300 conversations 1.6 → 1.0–1.2 85 → 60–66 ms 248–272 → 202–216 – 57 → 53–56 MB
Feed (reference, unchanged) 0.3 → 0.2–0.4 34–35 → 35–36 ms 58–61 → 57–58 – 64 MB

Chat-40 and artifacts head rows come from the post-fix reruns (5 and 3 runs); see the report.

The full report with before/after recordings is local to the author's Mac for now (artifact publishing needs a claude.ai login in that session); the review findings and their resolution are in the first PR comment.

Testing

  • Full CmuxMobileKit suite on aws-m4pro-5 at e7e3c9c53b54: 1,287 tests in 314 suites passed.
  • New suites include FeedStoreScaleTests, SmoothChatTests, EditPreviewTests, ChatNativeEligibilityTests, DiffPrepareTests, WorkspaceInboxIdentityTests, WorkspacesListPlanTests, WorkspacePublishTests, HomeListMemoTests, TitleActivityTests, CardResidencyPlanTests.
  • UITests/WorkspacesListUITests (new) on a simulator: long-press menu, trailing swipe Rename/Close, offline Mac fold, pull-down search, opening a workspace: passed twice.
  • CmuxTabsKit swift test 62 passed; WorkspaceTabsSourceListTests / WorkspaceTabsSourceUpdateTests on a simulator 16 passed.
  • An independent correctness review found 2 high, 6 medium and 5 low issues; all are fixed in this branch (frozen tab-card picture, VoiceOver in native chat rows, accessibility text sizes, shimmer restart, snapshot from layoutSubviews, stale heights, thumbnail cache key, read-mark loads waiting on disk, Reduce Motion for typing dots, HTML snapshot scale) or checked (trailing swipe works with the new cells).
  • prepush-gate.py: no new violations.

Observed / Not run

  • Observed: rig frame logs, Instruments-free sample profiles, simulator recordings and stills before and after, on aws-m4pro-6 and the lane minis.
  • Not run: the real iPhone (simulator only, per the owner's current rule); VoiceOver itself (accessibility trees were logged and checked); pull to refresh in Workspaces (mock mode has no refresh action); TabReorderUITests fails at its tab-grid drag check the same way on base and head.
  • Known remaining costs: opening a workspace from the list has a 120-210 ms frame, mostly off the main thread (suspected simulator render-server commit, not verified); main-thread CPU p99 per frame is 12-18 ms for Workspaces under live updates while hitches stay in budget; chat at accessibility text sizes is SwiftUI (13.5 ms p99 per frame).

No capture: iOS app (no Mac native UI changes); simulator recordings and stills are in the linked report.

Changelog

Changed: Lists in the cmux iPhone app (workspaces, agent chat, tab grid, diffs, files, Home) scroll smoothly while agents work, open large chats and diffs at once, and use far less memory.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Every vertical list in the cmux-next iOS app now scrolls at full frame rate, idle and while live data changes (agents streaming, a busy Mac publishing workspace updates). Workspaces, agent chat, tab grid, diff screen, files/artifacts, and Home lists are UICollectionViews of reused rows with stable IDs and diffable data sources; text, heights, diffs, thumbnails, and syntax highlighting are prepared off the main thread once per change.

  • Streaming chunks coalesce to one update per display frame; opening a 200-turn chat or 2,400-line diff now takes tens of milliseconds instead of tens of seconds.
  • The tab grid builds only cards near the screen and agent cards draw as pictures while the grid rests; the kit-only changes are also proposed in CmuxTabsKit: build only nearby grid cards; dormant hidden grid #26818.
  • A store change now reaches the stored tab list in the same turn, so code that changed a tab and read the list in the same turn no longer sees the old list (four WorkspaceTabsSourceTests caught by CI).
  • The branch is merged up with feat-cmux-next: Find in Chat, Edit/Resend, failure retry and Retry with another model, diff Search and hunk jumps, workspaces group menus, the Reduce Motion jump, and the disclosure folds (a tapped tool row, group, or thought keeps its line in place while its height springs — or lands at once with Reduce Motion) are ported into the new rows.
  • Adds a DEBUG-only list stress rig (verify/listperf/) that seeds and scrolls each list and logs frame intervals, main-thread CPU, and memory.

Note: don't merge until the owner dogfoods the tagged build.

Written for commit 3cc58c6. Summary will update on new commits.

View guided diff Turn on auto-fix

azooz2003-bit and others added 30 commits October 26, 2026 15:32
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>
…, memory, sample mode, minis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
… build per change, lookup moves

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d 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>
…en 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>
…okups

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>
…nus 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>
…plit stills)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
… 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…treamed 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>
…nged

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>
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>
…aits 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>
…pen'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>
…ames

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>
…rst 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>
…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>
…nd quiet unchanged publishes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…rds 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>
…ble 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>
azooz2003-bit and others added 2 commits October 30, 2026 18:43
…lists

13 files conflicted under cmux-next-ios/CmuxMobileKit/Sources. Both sides' behavior is
kept. Where this branch replaced the code an upstream commit changed, the upstream
change is ported into the new code:

- Transcript (TurnView/AgentChatTurns -> ChatTranscriptList rows): Find in Chat (#27318)
  marks and scrolls to the row that draws the match (ChatRow.findKey, layout frames,
  the marked row drawn as SwiftUI); Edit and Resend opens in the composer with the
  "Replace your draft?" dialog on both row paths (#27620); "Showing messages since"
  for capped history in the SwiftUI and native session line (#27374); the failure
  card retries through retryFailedTurn with Retry off while it is in flight (#27314);
  Retry keeps attachments (#28489) and Retry with another model in the SwiftUI and
  native More menu (#27322); native copy haptic follows the haptics setting (#27348).
- AgentChatStore: the branch's copy-free chunk append keeps upstream's startsNewAttempt.
- ChatKeyboardDock: upstream's header bottom, native top edge, iOS 26 fade, stack
  ceiling and find scroll (#27300, #27767, #27318) on the branch's collection view; the
  edge-inset safe area override moves to ChatTranscriptCollectionView.
- DiffFileScreen (VStacks -> DiffCodeList): Search in File, Next / Previous Hunk, match
  paint, current-match bar and scroll to line (#27322), memoized per file and query.
- WorkspacesList (SwiftUI menus -> WorkspacesListMenus): group Options, Close All in
  Group, Close Other Groups, Remove All Workspace Colors, Move to Top/Bottom, Sort
  Workspaces, Close Other in Group / All (#27322).
- WorkspaceSync: upstream's delta queue (#27299) kept; the repeated-title fix moves to
  enqueue. TerminalFeed: upstream's delta early return before the batched reads.
- FeedStore: markAllRead next to batched markRead. ArtifactPreview: cancellation check
  and off-main decode. DevicesPage: link status in the subtitle with the per-pass row
  context. WorkspaceTabsSource: Open on iPhone cards and pages, group edits and
  screens on the stored-tab model; MarkdownView: block marks; DisclosureChoice: both.
- Tests: SlashSuggestionsReachTests uses the controller's store initializer;
  TranscriptRowPortTests covers the ported transcript and diff behavior.

Per-commit table: cmux-next-ios/verify/listperf/MERGE-<this sha>.md (next commit).

Tests: macOS swift test (aws-m4pro-5) 1500 tests, the same 4 PreviewInsufficientSpace
failures as untouched upstream on that host; iOS xcodebuild test (aws-m4pro-6, iOS 26.5)
all suites pass but BoundedWaitTests under load, which passes on re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he merge lives and how it was checked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
azooz2003-bit and others added 2 commits October 30, 2026 19:57
…lists

One conflict, ChatKeyboardDock.scroll(to:animated:): kept both #27776's
Reduce Motion seam (`reducesMotion`, a jump lands at once with Reduce Motion on)
and this branch's find-scroll `settle` re-target. #27776's AgentChatScreen change
(Jump to latest checks Reduce Motion) merged as is.

#27776's JumpReduceMotionTests built the old one-hosting-view transcript
(`content:`), which this branch replaced: it now seeds a real store into the
collection-view transcript and checks the same two behaviors (with Reduce Motion
the long jump lands at once with no cut and no spring; without it the jump still
cuts one screen short and springs). The hidden-transcript-for-a-frame step no
longer exists (reused rows draw in the cut's frame), so its opacity check is gone.

Tests (fleet): iOS simulator CmuxMobileKit-Package on aws-m4pro-6: every suite
passed except the two jump tests, fixed here and then 3/3 green with
SmoothChatTests and TranscriptRowPortTests. macOS swift test on aws-m4pro-5:
1,544 tests, 4 failures that also fail on upstream on that host
(PreviewInsufficientSpace: volumeAvailableCapacityForImportantUsage reads 0 over SSH).

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

blacksmith-sh Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Found 3 test failures on Blacksmith runners:

Failures

Test View Logs
test_a_picked_signing_label_has_a_rerun_alternative (main.RunsOnFallbackTests.test_
a_picked_signing_label_has_a_rerun_alternative)/
test_a_picked_signing_label_has_a_rerun_alternative (main.RunsOnFallbackTests.test_
a_picked_signing_label_has_a_rerun_alternative)
View Logs
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)/
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)
View Logs
test_no_runs_on_chain_ends_on_the_reserved_side_label (main.RunsOnFallbackTests.tes
t_no_runs_on_chain_ends_on_the_reserved_side_label)/
test_no_runs_on_chain_ends_on_the_reserved_side_label (main.RunsOnFallbackTests.tes
t_no_runs_on_chain_ends_on_the_reserved_side_label)
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

azooz2003-bit and others added 3 commits October 30, 2026 22:58
…lists

Ports "agent chat disclosures open like ChatGPT's" (a5efcb9) and "a
disclosure's hold ends with its animation" (5d53628) onto the
collection-view transcript (ChatTranscriptList):

- ToolViews: rows keep their disclosure state in `chatDisclosures` (reused
  cells) and toggle it through `toggleDisclosure(holdFold)`; bodies use
  upstream's `.disclosureReveal`.
- ChatRowView gives each SwiftUI row `transcriptFoldHold` for its own row
  id (`ChatRowContext.foldHold`). Native tool, group and thought lines open
  through the same hold and animated toggle (`ChatRowContext.toggleFold`).
- ChatTranscriptList.beginHold: the tapped row is drawn by its SwiftUI view
  for the whole change. A native line is swapped for that view, drawn
  closed, before the change lands. The row is the reload anchor, so the
  newest row is never re-pinned. Its measured height moves on the
  disclosure's spring (`ChatFoldSpring`, Spring(duration: 0.35, bounce: 0),
  one layout per display frame, heights within 1 pt treated as equal), so
  the rows below move with its edge.
  The cell clips to that edge while its content stays top-aligned
  (`ChatRowCell.folding`, `foldableRow`). Reduce Motion applies the height
  at once.
- ChatKeyboardDock: holdForDisclosure(row:) sets following = false and
  hands the row to the list. While the row's height moves, holdFoldLine
  keeps the offset in range frame by frame, so it glides on that spring.
  Otherwise (a short chat, a keyboard move) it glides only when out of
  range, on UIView.animate(springDuration: 0.35, bounce: 0), and is
  instant with Reduce Motion. The hold ends on the animation's completion
  (toggleDisclosure's completionCriteria: .logicallyComplete); the row
  lets go once its height has landed (foldReleased). The 1.5 s cap, a
  drag, scrollTranscript(toTop:/toContentY:/toFindBlock:) and a store
  swap end it at once; a keyboard move does not. When it ends, a chat
  resting on its newest row (within 2 pt) follows again, never while a
  find is in progress. The old hosting view's held-height logic is
  dropped: the layout's height never snaps, so nothing needs holding.
- Tests: DisclosureFoldTests (line within 1 pt each frame of an open and a
  close; no re-follow during the hold; follows again back at the bottom;
  Reduce Motion at once).
- List rig: CMUX_MOBILE_LIST_TOGGLE=1 opens and closes the tool row nearest
  the screen's middle twice through the tap path and logs its line each
  frame (`list-stress toggle`).

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
teamleaderleo added a commit that referenced this pull request Oct 31, 2026
…st in the same turn

Brings the newest commit of #27087 onto the carry. The conflict was the
tab lookup: the carry made it internal (cards and pages outside the file
read it); azooz added the synchronous stale flush. Both are kept.

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

Copy link
Copy Markdown
Collaborator Author

CI at 3cc58c6cabcf (merge of feat-cmux-next 4dc7567): 150 pass, 5 red, none caused by this PR.

Red check Cause Class
cargo check (x86_64-pc-windows-gnu) #[cfg(unix)] covers one item in cmux-tui; this PR changes no Rust base (cmux-tui on feat-cmux-next)
nightly-guards / release-notary, Guard status guard could not read the runner pool / list Mac runners (HTTP 403/502 from GitHub) infrastructure
cmux-next fleet Release compile, cmux-next/compile "no result from the controller (attempt 1)": the fleet never answered infrastructure; rerun requested

This PR's own checks are green, including CmuxMobileKit tests (macOS swift test, iOS simulator).

The merge of feat-cmux-next carried over the "agent chat disclosures open like ChatGPT's" change (a5efcb9, 5d53628) onto the rebuilt transcript: the tapped line stays put while its body opens or closes (per-frame log: the line's top stayed at 405.67 pt through 4 open/close changes), no re-follow during the hold, Reduce Motion instant, the hold ends on the animation's completion. New DisclosureFoldTests (3) pass on an iOS 26.5 simulator.

@azooz2003-bit azooz2003-bit added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 31, 2026
teamleaderleo added a commit that referenced this pull request Oct 31, 2026
Base's iOS work carried onto #27087's transcript list, which replaces the
hosted SwiftUI transcript (TurnView):

- Edit and Resend opens the message in the composer (#27620): the list's
  user bubble and its native menu go through AgentChatSurface.editAndResend;
  a draft in the composer is replaced only after a Replace your draft?
  sheet (Copy Draft and Replace, Replace Draft, Cancel), as TurnView did.
- Retry keeps the turn's attachments (#28489): the list's answer row and
  native Retry call store.retry(turn).
- Disclosures hold their line (a5efcb9, 5d53628): rows get the dock's
  TranscriptFoldHold through ChatRowContext; the hold drives the inset
  pass (holdFoldLine) instead of a hosted content height, which the list
  does not have. Disclosure state stays in the list's chatDisclosures.
- Reduce Motion jump, devices same-name tags, close-question wait helper,
  WorkspaceSync bind reset, artifact load cancellation: both sides kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
teamleaderleo added a commit that referenced this pull request Oct 31, 2026
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>
@azooz2003-bit azooz2003-bit removed the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Oct 31, 2026
azooz2003-bit added a commit that referenced this pull request Oct 31, 2026
… 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). 3981ade
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 (b2e7a1a), onto #27664.

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

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

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

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

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

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

* cmux-next-ios list rig: lane brief

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* CmuxTabsKit: unbuilt grid cards stay in the accessibility tree

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

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

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

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

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

Test: WorkspaceTabsSourceListTests.olderPictureNeverHidesALoadingImage.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two findings of the independent slice reviews of #27664:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Both fail at 1ad5d8f6377 (review findings).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

From #27087 (b2e7a1ad56a9), onto #27664.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

aRowMeasuredUnderTheTopSafeAreaTakesItsOwnHeight puts …
@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Landed through #27664 (merged as 5e4c938), which carries every commit of this PR onto current feat-cmux-next plus the review fixes. Closing as replaced.

@teamleaderleo

Copy link
Copy Markdown
Collaborator

For awareness: the repair of this PR, #27664, landed on feat-cmux-next as 5e4c938. That puts every vertical iOS list at full frame rate. Whether to close this PR is your call.

Agent session: agent session

This branch was previously deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants