Repository navigation
Chats: acpmux owns the chat list; the sidebar history holds only its window (P0 stall) - #28055
Conversation
… rule; Swift is the platform shell Rules 1-3 and L7: a client asks Rust for its visible window and gets row diffs with a revision; a native Swift list holds only its visible rows as value snapshots; Swift is never a relay or a mirror for data a webview shows (Lawrence 2026-10-30, P-RUST).
…hange; main holds only the window Rust: acpmux answers _acpmux/chats_window over 3,000 chats with the rows sorted and filtered, and one change is a diff of one row with the next revision. Swift: after one chat change among 3,000, the main thread holds the sidebar history's window, not the whole index (v2.2.9 hangs of 570 to 1,148 ms in AcpmuxChatsStore.apply and SidebarChatHistoryFeed.chats).
…w, never the whole index
v2.2.9 hangs of 570 to 1,148 ms (Lawrence, debug.hangs 2026-10-30):
AcpmuxChatsStore.apply copied every chat because SidebarChatHistoryFeed.chats
held the same storage, and that setter ran the derived == over every chat and
freed the old copy, all on the main thread.
Rust (acpmux) now owns the list a client shows (P-RUST, layer-ownership.md
rules 1-3 and L7):
- _acpmux/chats_window {window, filter, limit, topLevel, exclude, keys}: the
window's rows, sorted, filtered and paged by acpmux; then, per index or chat
activity change, _acpmux/chats_window_changed with the next revision, the
window's keys in order and only the rows whose value changed. Level-triggered:
each window is recomputed from the index as it is now (chats/window.rs,
server/chat_windows.rs).
- _acpmux/chat_projects: each harness's project folders grouped by acpmux.
Swift:
- AcpmuxChatWindows holds only each window's visible rows, as value snapshots,
and applies a diff in O(window keys + changed rows); a revision gap, a
missing row or _acpmux/lagged asks for the window again (once).
- The sidebar chat history (Earlier and Pinned) reads two windows; its whole-
index setter is gone.
- ProjectsImport relays acpmux's grouped projects instead of taking a snapshot
of the mirror (which made the next main-actor change copy every chat).
- ChatsFeed's remaining mirror (palette chats page, Open Chat, cookie import,
New Tab cards) is private to it: no other holder, so a change applies in
place. Its removal is P-RUST step 2.
Tests: chats_window.rs (3,000 chats: sorted, filtered window; one change is
one row; a change outside the filter sends nothing; chat_projects groups),
window.rs unit tests, ChatsWindowMainThreadTests (one change among 3,000
touches one row on main; missed diffs ask again once).
…d state The guardrails count observations and main hops per file: the specs and the state now come from one ObservationStream, as before the windows.
|
cmux status (one comment, edited in place; each line is updated by the job that owns it) Review App of this head (app.zip, kept 1 day)
Gallery before/after for
States
Pick other states with a Not verified: macOS jobs did not run: compile and app tests were skipped on CI passes on Written by web and size checks: green Default changesThis pull request changes 1 default at
Signed off on this commit by Merge receipt: merged as Regenerate the snapshot with Shipped in:
|
…nd its connection (R6) chats_window_owned.rs counts live window tasks (a process-wide drop-guard count). Before the fix the task runs until the connection closes, so it outlives the client's last chats_window_close.
…d its connection (R6) The connection's one chats-window task ends when the client closes its last window (a later open starts a new one), when the connection closes, and with the runtime at daemon exit. Its running flag is set and cleared under the windows lock, so an open never misses a task that is ending.
|
For PL-acpmux (owner of acpmux), what this PR changes in acpmux: New methods (local unix socket only, like every
Files:
|
|
Swift proofs (read from the step logs):
|
|
All proofs, read from the step logs:
Compile ( |
…ux runs the requests in Review F1 of #28055: the daemon runs each request as its own task, so two specs of one window sent back to back may apply out of order, and the client then accepts the older spec's full message. Rust: 20 rounds of five specs in one burst must end on the last (limit 5); an older spec or close is refused as stale. Swift: each spec names a newer generation, and a message for an older one is not applied.
…28055 F1: the daemon runs each request as its own task, so two specs of one window sent back to back could apply out of order and leave the window on the older spec. Each open and close now names the window's generation (`gen`); acpmux keeps the highest per window, refuses an older one as `stale` and echoes `gen` in every message, and the client applies only its current spec's rows. F4: the windows lock is no longer held across an index read: rows are read off the executor, and each unchanged spec takes its diff under the lock (one recompute at a time, an async lock); an activity change takes no lock. F5: the batches waiting when a recompute starts are drained first, so one recompute follows a burst. F8: a recompute that fails sends `_acpmux/chats_lagged`, so the client asks again. F3: an error answer to a window request names its window (JSON-RPC id); the window is asked again at the next index change or reconnect, not left waiting. F7: an `_acpmux/lagged` for a message too large is not answered by asking again (it would loop). F2: `_acpmux/chat_projects` has no app reply cap (one row per chat folder); a failed read is logged and relayed again at the next index change (F6). F10: AcpmuxChatsStore.newestFirst and Window.hasMore (no readers) removed. F9 (one copy of the mirror per page) is left for step 2, which deletes it.
…ux runs the requests in Review F1 of #28055: the daemon runs each request as its own task, so two specs of one window sent back to back may apply out of order, and the client then accepts the older spec's full message. Rust: 20 rounds of five specs in one burst must end on the last (limit 5); an older spec or close is refused as stale. Swift: each spec names a newer generation, and a message for an older one is not applied.
…28055 F1: the daemon runs each request as its own task, so two specs of one window sent back to back could apply out of order and leave the window on the older spec. Each open and close now names the window's generation (`gen`); acpmux keeps the highest per window, refuses an older one as `stale` and echoes `gen` in every message, and the client applies only its current spec's rows. F4: the windows lock is no longer held across an index read: rows are read off the executor, and each unchanged spec takes its diff under the lock (one recompute at a time, an async lock); an activity change takes no lock. F5: the batches waiting when a recompute starts are drained first, so one recompute follows a burst. F8: a recompute that fails sends `_acpmux/chats_lagged`, so the client asks again. F3: an error answer to a window request names its window (JSON-RPC id); the window is asked again at the next index change or reconnect, not left waiting. F7: an `_acpmux/lagged` for a message too large is not answered by asking again (it would loop). F2: `_acpmux/chat_projects` has no app reply cap (one row per chat folder); a failed read is logged and relayed again at the next index change (F6). F10: AcpmuxChatsStore.newestFirst and Window.hasMore (no readers) removed. F9 (one copy of the mirror per page) is left for step 2, which deletes it.
|
Found 6 test failures on Blacksmith runners: Failures
|
|
Review (independent review subagent, Claude Opus): SHIP at 0bb76eb Round 1 at 29d8e4a was FIX FIRST (F1 out-of-order window specs, F2 reply cap); both are fixed at this head. Proofs at this head: compile 6682213462e5f8fabf70a6d3 ok; Rust F1 red 4df558fb (at 7800991) / green 279802c5; Rust window + ownership 8db9d3b0 5/5; Swift F1 red a9cd168a (at 7800991) / green 2a7a40b9 (ChatsWindowMainThreadTests 5/5, SidebarChatHistoryTests 14/14). |
# Conflicts: # cmux-tui/crates/acpmux/src/server/mod.rs
|
Review (independent review subagent, Claude Opus): SHIP at 7cca9aa No blocking findings. The merge resolution is correct, and I found no semantic conflict between feat's new code and the PR. FindingsNone block the merge. Three low-severity notes. Each can be a follow-up:
What I checked
Not runI read the code and did not build or run it. I ran no Swift or Rust build and no tests on this machine (repository rule). A separate fleet compile of this head is running, and this receipt does not show that it passed. |
Conflicts with #28055 (chats_window, chat_projects) in chats/mod.rs and server/chats.rs: both sides kept. Protocol IR re-blessed, methods.ts regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-80fea2c04bdd31e52544
#28055 fixed the chat index stall the layer-ownership way (acpmux owns the list, the sidebar holds a window), and 643cd1a reverted this crate's app FFI link. With no consumer, and as a client-side mirror that layer-ownership.md rule 2 forbids, the crate goes (aaa3118, 123cfaa). The audit's plan now targets daemon windows and intents, with FFI only for pure optimistic reducers; item 1 is P-RUST step 2 (remove ChatsFeed's private mirror).
Conflicts came from #28055 (step 1) landing as a squash: for every add/add file feat's version equals step 1's, so the PR's step-2 version stands (ChatsFeed, AcpmuxChatWindows, ChatsWindowMainThreadTests, chats/window.rs, server/chats.rs, tests/chats_window.rs; AcpmuxChatsStore stays deleted). server/mod.rs takes feat (the PR does not change it). AgentActivityLane keeps both the PR's preview and feat's usageWarnings. OnboardingHandlers keeps feat's shape (team.invite moved to home.inviteToTeam on feat) with the PR's AgentLaneStore lookup.
…pp passes its target, socket and connection The merge of feat (acpmux groups the projects, #28055) with this PR's retry backoff left CmuxNextApp 86 lines over its ratchet (arch-ratchet app_lines). ProjectsImport now knows no app type: AppServices passes the local daemon as a Target (Target(daemon:)), the acpmux socket path and the connection it follows. Behavior is unchanged; ProjectsImportRetryTests builds it with the same fake target, a nil socket and a nil connection.
…mux (step 2 of #28055) (#28090) * docs(layer-ownership): Rust owns every list, sort, filter, search and rule; Swift is the platform shell Rules 1-3 and L7: a client asks Rust for its visible window and gets row diffs with a revision; a native Swift list holds only its visible rows as value snapshots; Swift is never a relay or a mirror for data a webview shows (Lawrence 2026-10-30, P-RUST). * test(chats): red: a chats window over 3,000 chats sends one row per change; main holds only the window Rust: acpmux answers _acpmux/chats_window over 3,000 chats with the rows sorted and filtered, and one change is a diff of one row with the next revision. Swift: after one chat change among 3,000, the main thread holds the sidebar history's window, not the whole index (v2.2.9 hangs of 570 to 1,148 ms in AcpmuxChatsStore.apply and SidebarChatHistoryFeed.chats). * fix(chats): acpmux owns the chat list: native lists hold a Rust window, never the whole index v2.2.9 hangs of 570 to 1,148 ms (Lawrence, debug.hangs 2026-10-30): AcpmuxChatsStore.apply copied every chat because SidebarChatHistoryFeed.chats held the same storage, and that setter ran the derived == over every chat and freed the old copy, all on the main thread. Rust (acpmux) now owns the list a client shows (P-RUST, layer-ownership.md rules 1-3 and L7): - _acpmux/chats_window {window, filter, limit, topLevel, exclude, keys}: the window's rows, sorted, filtered and paged by acpmux; then, per index or chat activity change, _acpmux/chats_window_changed with the next revision, the window's keys in order and only the rows whose value changed. Level-triggered: each window is recomputed from the index as it is now (chats/window.rs, server/chat_windows.rs). - _acpmux/chat_projects: each harness's project folders grouped by acpmux. Swift: - AcpmuxChatWindows holds only each window's visible rows, as value snapshots, and applies a diff in O(window keys + changed rows); a revision gap, a missing row or _acpmux/lagged asks for the window again (once). - The sidebar chat history (Earlier and Pinned) reads two windows; its whole- index setter is gone. - ProjectsImport relays acpmux's grouped projects instead of taking a snapshot of the mirror (which made the next main-actor change copy every chat). - ChatsFeed's remaining mirror (palette chats page, Open Chat, cookie import, New Tab cards) is private to it: no other holder, so a change applies in place. Its removal is P-RUST step 2. Tests: chats_window.rs (3,000 chats: sorted, filtered window; one change is one row; a change outside the filter sends nothing; chat_projects groups), window.rs unit tests, ChatsWindowMainThreadTests (one change among 3,000 touches one row on main; missed diffs ask again once). * fix(chats): one observation for the sidebar history's window specs and state The guardrails count observations and main hops per file: the specs and the state now come from one ObservationStream, as before the windows. * test(chats): red: a chats window task must end with its last window and its connection (R6) chats_window_owned.rs counts live window tasks (a process-wide drop-guard count). Before the fix the task runs until the connection closes, so it outlives the client's last chats_window_close. * fix(chats): the window task is owned: it ends with its last window and its connection (R6) The connection's one chats-window task ends when the client closes its last window (a later open starts a new one), when the connection closes, and with the runtime at daemon exit. Its running flag is set and cleared under the windows lock, so an open never misses a task that is ending. * test(chats): red: the last window spec sent wins, whatever order acpmux runs the requests in Review F1 of #28055: the daemon runs each request as its own task, so two specs of one window sent back to back may apply out of order, and the client then accepts the older spec's full message. Rust: 20 rounds of five specs in one burst must end on the last (limit 5); an older spec or close is refused as stale. Swift: each spec names a newer generation, and a message for an older one is not applied. * fix(chats): window specs carry a generation; review fixes F2-F10 of #28055 F1: the daemon runs each request as its own task, so two specs of one window sent back to back could apply out of order and leave the window on the older spec. Each open and close now names the window's generation (`gen`); acpmux keeps the highest per window, refuses an older one as `stale` and echoes `gen` in every message, and the client applies only its current spec's rows. F4: the windows lock is no longer held across an index read: rows are read off the executor, and each unchanged spec takes its diff under the lock (one recompute at a time, an async lock); an activity change takes no lock. F5: the batches waiting when a recompute starts are drained first, so one recompute follows a burst. F8: a recompute that fails sends `_acpmux/chats_lagged`, so the client asks again. F3: an error answer to a window request names its window (JSON-RPC id); the window is asked again at the next index change or reconnect, not left waiting. F7: an `_acpmux/lagged` for a message too large is not answered by asking again (it would loop). F2: `_acpmux/chat_projects` has no app reply cap (one row per chat folder); a failed read is logged and relayed again at the next index change (F6). F10: AcpmuxChatsStore.newestFirst and Window.hasMore (no readers) removed. F9 (one copy of the mirror per page) is left for step 2, which deletes it. * test(chats): red: no chat of the index reaches main; chat_open names its chat Step 2 of P-RUST (R7): the app's Chats feed must hold no copy of acpmux's index. ChatsFeedHoldsNoIndexTests: a 3,000-chat watch page and one index change reach the main actor with no AcpmuxChat in the message (today: 3,000 and 1). chats_open.rs: _acpmux/chat_open also answers the chat itself, so Open Chat names it without the mirror. * fix(chats): delete the ChatsFeed index mirror; every reader asks acpmux (P-RUST step 2, R7) Step 2 of #28055: the app holds no copy of acpmux's chat index. The mirror (AcpmuxChatsStore) is deleted with its last readers: - New Tab cards: a window over the index (`newtab.cards`, the newest 8 top-level chats); only that window's messages push to the pages. - Command palette chats page: a window (`palette.chats`) whose spec carries the query; acpmux searches its own index (title or folder) and answers its newest 200 matches, then their changes while the page shows. The window closes with the page. No main-actor task: the rows arrive as window messages. - Open Chat: `_acpmux/chat_open` answers the chat with its plan; a running chat is found by its key (`harness:sessionId`). - Cookie import's requester: the caller's acpmux session lane by id, O(1) (the lane now carries the summary's `preview`). - ChatsFeed keeps the one connection, the windows and an index-change signal for the projects relay; the watch asks for a one-chat page, and its lines reach the main actor with no chat in them. This also removes review F9 of #28055 (one copy of the mirror per page). Behavior change: the palette's chats page lists acpmux's newest 200 matches of title or folder per query, instead of every chat filtered in the app by title, key, harness, folder, accounts and roots. * test(chats): red: the palette chats query stream ends after acpmux's first answer Review finding 1 of #28090: the page's queryResults stream never ended, so the palette spinner ran for as long as the page showed and an empty answer never showed the empty state. * fix(chats): the palette chats query stream ends after acpmux's first answer Review finding 1 of #28090: the palette runs its spinner until a query's stream ends; the stream now ends after acpmux's first answer for the query, so an empty answer shows the empty state. The window stays open until the page closes; an edit opens the next query's spec. Also removes the unused AgentPaneChatsPage.read (finding 3) and a stale test comment. * test(chats): red: a query window ranks like the palette and reports how many matched No-regression gate (lead, review of #28090): the palette's chats page found a chat by its harness ("codex", "claude") and by words that are not next to each other, with the palette ranker; the window's substring search found neither. 30 Claude chats: "claude" finds them all (total 30, five shown, hasMore), "fix build" finds the 15 whose title holds both words. * test(chats): red: the palette chats page names the matches past its page No-regression gate (review of #28090): acpmux reports how many chats matched; when more match than the page shows, the page's last row says so and runs nothing. The spec also sends the page's untitled word, so acpmux finds an untitled chat by it. * fix(chats): a query window ranks with the palette's ranker and reports the total (no regression) Review of #28090 under the no-regression gate: the palette's chats page found a chat by its harness ("codex") and by words anywhere in its title or folder, with the palette ranker; the window's substring search found neither. - acpmux: a window with a `query` ranks every chat its filter and exclusions pass with cmux-palette-ranker (the ranker the palette itself runs through its C ABI): the same entry shape the page built (title or the client's `untitled` word, the folder with ~ as subtitle, the harness and folder as keywords), best match first; it sends the first `limit` (200) and `total`, how many matched. A window with no query is unchanged (newest first). - Swift: the spec sends `untitled`; a window keeps `total`; the palette page adds a last, disabled row "The best 200 of N matching chats. Type more to narrow." when more match than it shows (all nine macOS locales). Not the same as before: no frecency (the page passed none it had for chats), and the ranker runs over the chats' metadata in acpmux, off the main thread. * refactor(chats): ChatsFeed moves out of CmuxNextApp into CmuxNextAgentLanes (SW14) The perf train refused the port of this fix: app_lines rose (plans/cmux-next/ code-quality.md SW14; feat reported +18). ChatsFeed is the acpmux chat index connection: it moves next to AgentTurnStateFeed, its sibling watch, in CmuxNextAgentLanes. Its App-only debug timing is injected through init (no static hook). The App files that name the feed import CmuxNextAgentLanes. No behavior change. * chore(chats): the main-actor work baseline follows ChatsFeed's move * fix(chats): the palette chats page's answers take an open closure, not AppServices Swift green 461f8727 at ff90606: AgentChatsPaletteAnswerTests built a whole AppServices in a test process and trapped at AppServices.swift:87 (an implicitly unwrapped value set only in a running app). The page's answers need only the windows and the Open Chat path, so they take an open closure; the page passes services.chatsOpener. The tests pass a no-op. No behavior change. * test(chats): the palette stream-end test reads two answers, so a stream with no end fails, not hangs The red proof of the stream-end fix needs a failure on the assertion: with no end, the second read gets acpmux's second message instead of nil. * fix(chats): a closure with no parameters takes no `in` (compile b8576df3: AgentChatsPalettePage.swift:86:60 expected expression) * test(chats): red: a query window ranks past choices first; a counted window reports its total Lead (no-regression gate, #28090): the palette ranked a chat the person chose before above one with an equal match (frecency), and the empty query must say how many more chats there are. 30 chats titled alike: the oldest, chosen lately, ranks first for "fix build"; a no-query window with count: true reports total 30. * fix(chats): the palette chats window ranks with the person's choice history and counts the empty query (no regression) Lead decisions on #28090 (no-regression gate): - Frecency: the palette ranked a chat the person chose before above an equal match. The page now sends its choice history of chats with each query (FrecencyStore.rankerHistory: the palette ranker's own {now, frecency} payload, only `chat:` keys and the picks that begin the query); acpmux gives each chat the frecency key `chat:<key>` (the palette row's id, which the palette records on a choice) and ranks with it, so the same history gives the same order as before. - Empty query: a window with `count` reports the total of chats it could show (counted without building values past the page); the page's last row says "N more chats. Type to search." (plural, all macOS locales). - Relative-date text ("yesterday") is a known difference (PR body, follow-up 2). * test(chats): red: the "N more chats" row picks each language's plural form Lead (review of #28090): the row's string was formatted with String(format:) over the catalog's value, and its test compared the function with itself, so it could never fail. AgentChatsMoreChatsPluralsTests reads English (1, 2, 2,999), French (1, 2) and Russian (1, 3, 5) from their tables. moreChats takes a bundle and locale so a test can pick a language; its body is unchanged. Also: the stream-end test gets a one-minute .timeLimit (a stream with no end fails at once), and the empty-query test asserts the count in the shown text. * fix(chats): the "N more chats" row uses the plural pattern; acpmux parses the history once Lead (review of #28090): moreChats puts the count in the localized default value ("\(more) more chats. Type to search."), the repo's plural pattern, so Foundation picks each language's form from the catalog (all locales as before). acpmux parses a window spec's history once when it reads the spec (two specs stay equal by the text the client sent), not at each recompute. * Chats palette page lives in CmuxNextAgentLanes; the App only wires it The page (its acpmux window query, rows and notices) and its strings move out of CmuxNextApp into CmuxNextAgentLanes, next to ChatsFeed. The App builds it from AppServices: the feed's windows, the palette's choice history and Open Chat. The New Tab cards' window spec moves next to AcpmuxChatWindows.Spec. * Cookie import requester reads the lane store AppServices holds, not AgentLaneStore.shared AppServices holds this Mac's acpmux session lanes and passes them to the turn-state watch it starts; the requester looks the caller's session up there. The palette page tests import the page's new module. * test(palette): Search and the Chats & agents scope list the chats acpmux answers (red) Review of #28090 at 63702fa (F1): the chats page answers each query through acpmux (queryResults) and has no providers, and Search reads a contributed page only through its providers, so Search and the Chats & agents scope listed no chats. The suite opens both with the real AgentChatsPalettePage, contributed as PaletteSourcesBridge contributes it. * fix(palette): Search and the Chats & agents scope ask a contributed page that answers its own queries A contributed page with queryResults (the agent chats page asks acpmux for each query) had no providers, and Search and the Chats & agents scope read contributions only through providers, so neither listed a chat. They now ask such a page for each typed query (the Chats scope also for the empty one) through one shared path, PaletteContributedAnswers: the answer lands in a later turn under the contribution's section beside the ranked rows, a new query cancels the old stream, and leaving the page closes the page's source (acpmux's window). An answer never holds the rows from being final, so Return never waits on acpmux. sidebar-search.md says so. * bench(palette): --search-keys times typed keys on Search (Cmd-Shift-A), the palette-key path palette-open-stages.py timed only palette opens. With --search-keys WORD it opens Search from the terminal tab and types WORD a key at a time through debug.key (target palette): each key's synchronous round trip (the main thread's whole handling of the key, PaletteModel.search's turn included) as p50/p95/max, and the longest main-thread stall while typing. Report only; no CI. * test(palette): contributed answers keep the new query's rows, the selection and the chats scope's Return (red) Review 2 of #28090 (issuecomment-6542219906): an answer that lands before the new query's rank showed the old ranked rows as final, so a waiting Return ran an old row (F1); a late chats block went above the selected best row and dropped out at each key (F2); in the Chats & agents scope a Return before acpmux answered ran a session or nothing (F3); and after a reconnect an unchanged window counted as answered with the old connection's rows. * fix(palette): a contributed answer never stands for another query's rows, keeps the selected row in view, and leads the Chats scope Review 2 of #28090 (issuecomment-6542219906): - F1: an answer shows again with the ranked rows only when they are the current query's (rankedGeneration); one that lands first waits for the rank, whose delivery adds it. - F2: an answer that does not lead goes after the best ranked section (where the ranker put the best row), capped to the rows the page's 400-row limit leaves; a new query keeps the old answer until its own replaces it; when rows above the selected one grow, the list scrolls to it. - F3: in the Chats & agents scope the chats lead the page and hold its query's final mark until they answer (the rows stay provisional), so a Return waits for the best chat, as on the chats page. - F4: the page is asked in a later turn than the key's; the chats' choice history is encoded off the main thread (FrecencyStore.encodedRankerHistory) and ChatsFeed encodes each request on the connection's serial queue (in order), so search() does no JSON work. - AcpmuxChatWindows.isAnswered: a window asked again (reconnect, lag, broken diff) is not answered until its full answer comes. * fix(palette): the chats' choice history is built off the main actor (nonisolated rankerHistory) FrecencyStore+RankerHistory sits under the package's main-actor default isolation, so the detached task in encodedRankerHistory could not call rankerHistory (fleet compile 336252fce1b5). The builder reads only its Sendable store value and the nonisolated bridge payload types. * bench(palette): time each typed query to the frames that show its rows and its contributed answer, on STRESS with seeded chats palette-open-stages.py --search-keys timed only the debug.key round trip, which ends before the later-turn work. Two always-on signposts (CmuxNextSignposts, key category, Release too, not mirrored to MetricKit): key.palette_query_rows, the search field's edit to the commit of the frame that first shows the query's final ranked rows, and key.palette_query_answer, to the one that first shows a contributed answer for it (end message rows=<n>). The palette's view ends them at the commit of the frame it applies. The bench reads them with perf-signposts.py's log stream recording and reports key rpc, key-to-rows-frame and key-to-answer-frame as p50/p95/p99/max with the answers that landed, seeds the app's acpmux store with --seed-chats N chats (idle_cost_load) and builds the STRESS session with --stress. Report only; no CI. * test(palette): an old query's chats are stand-ins, a short query keeps the best chats, no answer never holds Return (red) Review 3 of #28090 (issuecomment-6543785296): F1 an old query's chat ran on Return under a query with no ranked rows, and stayed under later queries while acpmux did not answer; F2 a one-letter query that filled the page's 400 rows showed no chats; M1 the Chats & agents scope's Return waited without end when acpmux did not answer; M2 a refused request after a reconnect counted as answered. * fix(palette): an older query's answer is a stand-in only; chats keep a share of a full Search page; no answer never holds Return Review 3 of #28090 (issuecomment-6543785296): - F1: each answer section records the query it answers. An older query's answer shows only as a stand-in: navigation never selects it (the reducer's rows and the keyboard moves leave it out), and it is never the default row or final. While an answer is awaited and the query has no ranked row, the rows are not final, so Return waits for the current answer; an answer that ends empty (refused, not connected, no match) takes the stand-ins off at once. - F2: an answer that does not lead shows its best 10 rows in Search, taken from the weakest ranked rows when the 400-row page is full, so a one-letter query still shows the best chats. - M1: the chats page's answer ends at once with no rows when acpmux is not connected or refuses the request (AcpmuxChatWindows refusal followers, isConnected, isRefused); a request in flight on a dropped connection counts as refused. Any wait is bounded at 1.5 s. - M2: isAnswered is false while acpmux refused the window's request. - M3: sidebar-search.md says what runs on the main thread and when Return waits. * test(palette): the no-acpmux test's expectations compile (a closure, not an ambiguous key path in #expect) * bench(palette): palette-open-stages.py prints why it stopped (the finally block's exit hid the exception) and logs the STRESS build * test(palette): Search's chats share is a minimum with a more row; a stale refusal, a not-ready index and a reconnect (red) Review 4 of #28090 (issuecomment-6546769110): F1 Search cut every chats answer to 10 rows with no row saying more match (30 chats on a page with room showed 10); note 2 a ready:false answer left Return on a timer; note 3 a refusal of an older spec ended the current query; note 4 End stopped on a stand-in on a full page (selectableRows compared counts); note 6 the chats page did not ask again when acpmux came. The seams these call (PalettePageSpec.moreRow and onSourceChange, AcpmuxChatWindows.requestFailed(_:generation:), requestNotReady, followAvailability) are inert here. * fix(palette): Search's chats share is a minimum with a more row; no timer: not-ready ends the wait, a stale refusal is ignored, a reconnect asks again Review 4 of #28090 (issuecomment-6546769110): - F1: a non-leading answer gets every row the ranked rows leave under the 400-row page and at least 10, taken from the weakest ranked rows. A cut answer ends with its page's more row (PalettePageSpec.moreRow): the chats page's says how many more chats match (the cut rows plus those past acpmux's 200) and opens the Agent Chats page with the same query. The page's own notice rows stay while nothing is cut. New string handlers.agentChats.moreInSearch, 21 locales. - Note 2: no hold timer. acpmux's ready:false answer ends the query's answer at once (requestNotReady), as a refusal does; every request ends with rows, none, or the query moving on. - Note 3: ChatsFeed keeps each window request's generation; requestFailed and requestNotReady ignore an older spec's answer. - Note 4: selectableRows filters by row id, so End and Down never stop on a stand-in on a full page. - Note 5: follow and followRefusal drop the entries of dead owners as they add one. - Note 6: PalettePageSpec.onSourceChange: the chats page asks the shown query again when acpmux connects or its not-ready index fills the window (AcpmuxChatWindows.followAvailability); the subscription lives while the page is open. - Note 7: the PaletteContributedAnswers doc and sidebar-search.md say when an answer holds the rows. * test(palette): the more-row test waits for the opened page without settling on its query; End must move off the first row * bench(palette): palette-open-stages.py --vdisplay HZ runs on a 1920x1200 virtual main display, so STRESS's 1600x1000 window fits (cmux23's 1920x1080 display left 963 pt) * bench(palette): palette-open-stages.py --search-keys asks the app's own acpmux for the typed word and prints its index state and matches (why no chats answer landed) * test(palette): an empty answer delivers nothing; nav rows stay on screen with stand-ins; the more row keeps its count; ChatsFeed not-ready and generation (red) Review 5 of #28090 (issuecomment-6556223157): F2 an empty answer ran two full deliveries (list reload and reducer pass) that changed nothing; note 3 with stand-ins on a full page the selectable list kept ranked rows the shown list had cut; note 2 the more row read the live window, which the pushed page's cancel closes; note 4 ChatsFeed's not-ready and stale decoding and the generation it keeps had no test. ChatsFeed.windowRequest(method:params:) is an inert seam here. * fix(palette): an answer that changes nothing delivers nothing; one cut for shown and selectable; the more row counts from the answer Review 5 of #28090 (issuecomment-6556223157): - F2: answer changes are redelivered once per main-thread turn (scheduleRedelivery), and only when what a delivery would show or hold changed (answerFingerprint vs the last delivery's): an empty answer with no stand-ins costs no list reload and no reducer pass; a yield and the stream's end no longer deliver twice. - Note 3: the selectable rows are the shown rows without the stand-in sections, so both cut the ranked rows at the same row and the selection is never off screen. - Note 2: the chats page records how many matches each answer left past its page (PastPageCounts) and the more row reads that, not the live window, which the pushed page's cancel closes. - Note 4: ChatsFeed.windowRequest(method:params:) is the one place a request's window and generation are kept. - Note 1: the awaitedAnswers doc no longer names the deleted holdLimit. * bench(palette): seed chats where acpmux's chat index reads them; check rows before typing; count chat rows per key Review 5 F1: --seed-chats wrote acpmux's own session store, which the chat index never scans, so no answer landed on either side. It now writes Claude Code transcripts in <HOME>/.claude/projects (a default root of cmux-chat-index's roots table) of a HOME the app gets of its own under /tmp; the account's ~/.claude is never touched. Before typing, the bench asks the app's acpmux for the word until the index answers with rows, and fails the run otherwise. After each key it reads the chat rows on screen (debug.palette.capture rows:true, a DEBUG and bench-hooks route), so the base, whose chats are ranked rows, is measured on the same check: key.palette_query_rows is its frame that first shows chat rows. * bench(palette): the per-key chat-row count reads debug.palette.capture's rows (one App line, no new route branch) * refactor(palette): PaletteModel.rankerHistory(keyPrefix:query:) encodes the choice history; the App's chats page only wires it * test(palette): split #require from the call of its result (the type checker crashed on the chained form, signal 6) * test(palette): explicit closure types in AgentChatsPaletteAnswerTests (Swift 6.3.3 type-checker crash) The merge-head compile 5d75c0a66d245630f25fea38 crashed swift-frontend 6.3.3 (signal 6, ConstraintSystem.cpp:4138 recordArgumentList) in theMoreRowKeepsItsCountAfterTheWindowCloses. Every windows.connected closure states (String, [String: any Sendable]), and the heterogeneous row literal's map closure states (Int) -> [String: Any]. Same assertions. * test(palette): an answer with the same rows retitled reaches the screen (red) Review 6 of #28090: the skip of a no-change delivery compared only answer row ids, so a re-answer with the same chats under new titles was dropped. * fix(palette): skip a redelivery only when what it shows and holds is the same Review 6 of #28090: the answer fingerprint held the raw awaited set, so an empty answer ending under ranked rows still reloaded the list and ran the reducer (anEmptyAnswerDeliversNothing failed, step 9e19c1b1878a14e485b60be6). It now holds whether the rows are held (one shared rule, isHeld, which joined() uses too) and each answer row as it shows, so a retitled chat with the same id still reaches the screen. Builds on the fork's 7e82205 (bench/pr28090-fp-fix).
![Fix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/fix-with-codesmith-light-3x.png)
Summary
Cause (base measurement, not a gain of this PR). Lawrence's v2.2.9-opt
debug.hangs(2026-10-30) show main-thread hangs of 1,148, 730, 656 and 570 ms in the chat index mirror:AcpmuxChatsStore.apply(Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxChatsStore.swift:93,values[key] = chat) copied the whole[String: AcpmuxChat]dictionary for each chat change, becauseSidebarChatHistoryFeed.chatsheld the same copy-on-write storage (ChatsFeed+Start.swift:29).SidebarChatHistoryFeed.chatssetter (SidebarChatHistoryFeed.swift:22) ran the derivedAcpmuxChatsStore.==over every chat and freed the old copy.Fix (P-RUST, Lawrence 2026-10-30: "move as much as possible into Rust"): acpmux owns the chat list that a client shows.
_acpmux/chats_window {window, filter, limit, topLevel, exclude, keys}answers the window's rows, sorted, filtered and paged by acpmux. After each index or chat activity change,_acpmux/chats_window_changedsends the next revision, the window's keys in order and only the rows whose value changed. A window is calculated again from the index as it is now (level-triggered)._acpmux/chat_projectsgroups the index into each harness's project folders. The connection's window task is owned (R6): it ends with the last window, the connection or the runtime.AcpmuxChatWindowskeeps only the visible rows of each window (value snapshots) and applies a diff in O(window keys + changed rows). A revision gap, an unknown row or_acpmux/laggedasks for the window again, once. The sidebar chat history (Earlier and Pinned) reads two windows. Its whole-index setter is deleted.ProjectsImportrelays acpmux's grouped projects and takes no mirror snapshot.plans/cmux-next/layer-ownership.mdrules 1-3 and L7: Rust owns every state, list, sort, filter, search and rule. Swift is only the platform shell, and never a relay or a mirror for data that a webview shows.What is measured. For one chat change, the main-thread work goes from a copy of all N chats plus a deep
==over N to O(changed rows). Measured as counts (rows touched, not ms) by the red job fe50d4f83638994ca7517f0d and the green job 6479f2dce62c06698cfa9745 (ChatsWindowMainThreadTests, a 3,000-chat index). No ms before/after is measured yet. When the Austin's-day harness gives adebug.hangsresult for the chats sites, it goes in a follow-up comment.Step 2 (follow-up in this series, R7): the palette chats page, Open Chat, cookie import and the New Tab cards still read the private mirror of
ChatsFeed. No other object holds it, so a change applies in place. The ChatsFeed mirror is deleted in #28090.Changelog
Changed: The sidebar's chat history gets its rows from acpmux, which sorts and filters the list; the app keeps only the rows it shows.
Testing
At head 7cca9aa (merge of feat-cmux-next 98f84fb into 2e0e343). The red commits are the red test b6d2aef cherry-picked onto the base (
proof/28055-chats-window-red-98f84fb,proof/28055-chats-window-red-fe8bef7).cmux-tui-rust-check.sh test three_thousand_chats.package-test-lane.sh suite Packages/macOS/CmuxNext ChatsWindowMainThreadTests.SidebarChatHistoryTests: 5/5 and 14/14 passed (job 64d8360ed1b2572602373234).--package CmuxNext --app --recipe-version 2) of the head: CmuxNext, app and cmux-tui passed, no errors in the log (job 616a151e91e340b5964be06f).Observed / Not run
Observed: I read the logs of every job above. The two reds failed on their own assertions; the greens passed; the compile passed. The only merge conflict was the acpmux
Connstruct (both new fields kept).Not run: a GUI load with many chats (ms before/after); the Austin's-day harness will give it.
R6(chats_window_owned) was not run again at this head. Web lint on push: the webviews checksbun run check:design-tokens,bun run check:layers,vp fmt --checkandvp lintfailed only inwebviews/src/pages/settings(from #25592). They are red on base fe8bef7, and this PR does not touch those files. The push usedCMUX_WEB_LINT_SKIP_REASONwith that check list and base sha.No capture: no visible UI change (the same rows, from a different source).