Repository navigation
perf(desktop): reduce chat-open latency and keep reopened transcripts in memory - #1112
Conversation
Adds data-testid, data-agent-ref, data-chat-id and aria-current to the agent chat rows and the latest-session rows, and a user-message test id on user bubbles, so Playwright can address a specific chat without matching on titles. Attribute-only; no behaviour change. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s across versions
Moves the chat:getIndex logic into an Electron-free runChatGetIndex
function (chatGetIndexHandler.ts). The IPC handler calls it with the same
cleanup-then-read order and the same warning, so behaviour is unchanged.
Adds scripts/bench-chat-open.mjs, a Node benchmark over ChatStore at
v0.10.0, v0.11.0 and HEAD, compiled from read-only git archives. It
measures the first chat:getIndex after a bind, setLastActive and
loadMessages over {5,25,100} agents x {20,200,1000} chats with 0 or 10
pending deleted-chat cleanups per agent, plus a persistent EACCES case.
No Electron, cluster or user data directory is involved.
Run the benchmark's own tests with:
node --test scripts/bench-chat-open/benchmark.test.mjs
Refs #1091
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add a Playwright spec that launches a before and an after Desktop build against the same branch profile and compares chat-open latency for the stateful and stateless Hosts: - O: cached open time in ABBA rounds, with the A/A spread from rounds 1 and 4 as the noise budget; the first pass reports the remote open time. - L: relaunch with 10 pending delete cleanups in the saved cache. - W: first stateful row while the stateless Host is suspended. Rounds inside a scenario must run under a 1-minute load spread of at most 1.0. The spec runs only when its config is selected explicitly. launchDesktopApp accepts an absolute dist/main.js so both builds share the same launcher. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cleanup chat:getIndex awaited the retry of queued deleted-chat artifact cleanup before reading the index, so every chat list load paid for removing artifacts of chats the user had already deleted. With 10 pending cleanups per agent this dominated the first index read after a bind. Start the cleanup in the background and read the index immediately. The index still carries the durable tombstones the renderer filters deleted chats with, so a chat whose artifacts are still being removed stays hidden. The rejection handler is attached synchronously; a failure keeps the base warning text and leaves the cleanup queued for a later read. Duplicate background sweeps are accepted: per-chat and per-agent serialization in ChatStore keeps them idempotent. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
switchToChat awaited chatStore.setLastActive before reading the cached transcript, so every chat open waited for a full index read-modify-write queued behind any other index write for the same agent. Start the write first, keep the Host-access and deleted-chat guard before the cache read, and render the cached messages without waiting for it. The selection still awaits the write when it finishes and rejects with the write error, so a persistence failure is not lost. ChatStore skips the index write when the last-active chat is already the requested one; the check runs inside the per-agent index chain, after the deleted-chat fence. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ives The latest-sessions list waited for every Host's remote catalog before publishing any of them, so one slow Host (for example a stateless Host waking from suspension) delayed the rows of every other Host. Each Host now publishes its own rows as soon as its catalog read settles: it merges its server page with its cached rows and keeps the other Hosts' entries untouched. All requests still start together and no Host waits for another. Authority epoch, blocked-Host, scope and tombstone checks run again when each response arrives, and pending renames are flushed only for the arriving Host. The local cache phase is unchanged. The aggregate Promise.all now only gates the final spinner reset, and an unexpected callback error still propagates. Refs #827 (step 1) Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…itcher The agent switcher labels each agent with its display name, so selecting the menu item by agent ref failed when the two differ (chatllm is shown as chatLLM). openExactAgentChat accepts an optional label, defaulting to the ref, and the chat-open A/B spec reads each Host's display name from the access catalog before the measured rounds. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open stopwatch waited for the sidebar session row to carry aria-current, but the sidebar previews only the five latest sessions, so any measured chat outside that preview could never stop the timer. The stop condition now follows the plan: a single transcript with the expected message count and last text, with a precondition that every measured last text is unique. Outside the timing, each chat must be the persisted last-active chat, and the sidebar must mark it active when it has a row and mark nothing active otherwise. L and W get named preconditions for the sidebar rows they time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…B run A/B run3 relaunched the AFTER build onto the login screen although the session token written by the BEFORE build was still present, and a standalone relaunch probe could not reproduce it. Each launch now keeps the Electron main-process output, and a failed authentication check records whether the stored token decrypts and what the session state reports, without any token material. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A closed build's background requests can exhaust control-api's per-minute limits, so the next build's session restore fails and it opens on the login screen. Wait 65 s after the previous close before launching, outside every timed section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
L times the selected chat's sidebar row, but the sidebar only previews the five latest sessions, so the first measured chat may have no row. Select the first measured chat inside the preview and fail loudly when there is none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The run5 diagnosis was wrong: the sidebar was empty because the startup access-catalog request was rate limited, which leaves no agent names for the latest-sessions loader. Restore the original L chat selection. This reverts commit bcd097f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mited rounds The A/B spec reached each chat through Settings -> Agents. Every Agents visit refreshes the access catalog and the team directory, so at about four opens per second a launch exhausted the per-minute limits of control-api /external/users (120/min per session) and the team directory (10/min per user). The failed catalog left the sidebar empty, which made the L precondition fail in runs 5 and 6 and let the O sidebar check pass on an empty preview. - Switch Hosts with the Chat page "Switch chat agent" menu, which neither changes route nor refreshes the catalog. - Fail a round whose main-process log contains a 429, since its timings ran on stale or empty catalogs. - Require a visible sidebar row before the O "none is active" check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Run7 still logged 429s after the Agents-page detour was removed. A probe that instruments the main-process fetch counted about 8 external-rest-api requests per chat open (me/teams, workflows, rpc/token, shared filesystems and, on some opens, the full access catalog), identical in the BEFORE and AFTER builds (98 requests each for the same 6 opens). At 60 opens in about 40 s that exceeds control-api's 120/min per-session limit on /external/users and /external/teams. Space opens 4 s apart, outside the timed section, and raise the test timeout to 90 minutes for the slower rounds. The per-round 429 check stays and fails loudly if the pace is not enough. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
L picked the first catalog chat and assumed it was in the sidebar preview. The preview orders cached chats by their first remote load, and round 1 loaded the stateless Host last, so all five preview rows were stateless. - Open the stateless Host first in each O round. - Pick L's chat as the stateful chat this run loaded most recently, read from the run's own chat cache; fail if any catalog chat is not cached. - Check in setup that both Hosts have a session among the server's latest SIDEBAR_SESSION_PREVIEW_LIMIT sessions, which W relies on. - Fail if the isolation directory exists before the first launch, so no run can pass on history left by an earlier one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With opens paced at 4 s, an O round lasts 6-9 minutes. Under the 1-minute stateless idle cadence that W needs, the stateless Host suspended and woke inside every O round, so all four O rounds of run8 were invalidated, and in run9 it was still suspended when round 2 started. CHAT_OPEN_AB_PHASE selects one phase per invocation, so the coordinator can run O and L under the normal cadence and W under the accelerated one: - OL: cached chat opens and the large-cache relaunch, with their gates. - W: the suspended-stateless relaunch, with its gate and the check that both Hosts have a session among the server's latest sessions. Each phase writes to its own output directory, because Playwright empties outputDir on every run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching back to a chat cleared its transcript and showed the spinner until the disk cache was read again; the Host reconcile then ran on top. Since #849 the reconcile always re-fetches the newest turn, and on a suspended stateless Host that request waits for the wake, so the blank window became visible. The renderer now keeps the last 8 transcripts it showed, keyed by auth scope, Host and chat. Reopening one renders it at once. The disk page is still read: when it matches the end of the remembered transcript the list and paging offset are kept, otherwise the disk page replaces it. The Host reconcile runs unchanged (afterTurn = latest - 1). The memo is cleared on resetChat and on an auth-scope change, and a Host's entries are dropped when its access is revoked or held. 'specific' selections (cross-Host opens) use the memo as well; implicit and 'latest' selections still clear, because they do not know the chat until the index returns. Stateful and stateless Hosts take the same path; the tests run every case for both. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scenario O opened every chat two or three times in one app session, so open_cached_ms mixed disk reads with transcripts the AFTER build now keeps in memory. Split it: - open_cached_ms: the first open of each chat after launch (disk path), gated as before against the A/A spread. - open_memo_ms: the reopen pass. It reopens the last min(K, 8) chats, newest first, so every reopen stays inside the 8-entry memory window. Gate: AFTER p50 < BEFORE p50. - reopen_blank_witness: a MutationObserver records whether the transcript was empty between the click and the measurement. AFTER reopens must never be blank; AFTER first opens and BEFORE reopens must be blank at least once, which proves the observer sees the empty frame. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tness openChat clicks the Chat breadcrumb before each open, which clears the active chat and leaves a message list with no bubbles. The C8 witness counted zero bubbles after the click as blank, so it matched that previous view on every open (run11: 144/144 blank, including reopens served from memory in 4 ms). The witness now records blank when the "Loading conversation" skeleton is visible after the click. The skeleton is what the user sees while a chat loads, and both builds render it. BEFORE reopens must all be blank, AFTER first opens must show it at least once, and AFTER reopens never. Each open also records up to 8 observer samples for diagnosis. loadavg_spread moves from the gates to an informational loadSpread entry while another branch profile shares the machine. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Evenfire Reviewer — Round 1Reviewed head: Decision: Approve with follow-ups The three latency changes hold: This comment reviews Must fix
Should fix
Verified
Validation gaps
PR descriptionAccurate on the latency changes and honest about the open A/B gates. Add R1-M1, or describe the fix once it lands. 30 files, one service. Pre-existingPre-existing: none identified during the scoped review. Next actionFix R1-M1 with a regression that reopens a remembered chat whose index carries a tombstone before Full review archive: — Review performed by Evenfire Reviewer |
Review R1-M1 on #1112. A reopen paints the remembered transcript before this session has loaded the Host's durable tombstones, and ChatStore.loadMessages returns [] for a tombstoned chat. The keep rule treated that empty page as "disk has nothing, keep memory", so a deleted chat kept showing its transcript. A failed disk read, which also leaves an empty page, was kept the same way. An empty page now wins like any other mismatching page: the list is replaced with it, which is what the path without a memo shows, and recording [] removes the memo entry. The regression reopens a remembered chat whose disk page has become empty and checks that a later open is no longer a memo hit; restoring the old rule turns it red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. Response to round 1: R1-M1 — fixed in
PR description — updated with R1-M1 and the current A/B status. Please review 🤖 Generated with Claude Code |
jozer-rami
left a comment
There was a problem hiding this comment.
Automated review by Evenfire — comments only, not an approval.
This pull request is too large for automated review: it changes more than 3000 lines or more than 100 files.
A chat deleted after its transcript was remembered could be painted again from the memo, and a Host held for access could keep recording into it. - switchToChat drops the memo entry of a deleted chat and clears the view when that chat is visible. - The record effect skips held Hosts and deleted chats. - A disk page of the same length but shifted content replaces the remembered transcript (covered by a new test). - The per-Host latest-sessions publication merges the arriving Host's sorted rows into the other Hosts' sorted rows in linear time instead of re-sorting the whole list. - Stateless-wake and memo tests run for both Host kinds; stale hold/release comment fixed. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The IPC test now gates the cleanup retry on a pending promise and asserts chat:getIndex returns the index with its tombstones while the retry is still pending, then that the retry completes once released. Restoring an inline await in runChatGetIndex makes it fail. Also completes the ChatIndex fixture of the handler test with onboardingDismissed. Refs #1091 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Took a third pass over this one at Verdict: 🔴 Not ready to approve
This is my read from the review, not a formal GH approval. The single blocker is the A/B re-run you already agreed to. Once that lands with the numbers, this flips to 🟡. Must resolve before approval1. [R1-M1] A/B, live L vacuity and T2 on the final head (still open since round 1) The body still reports the A/B from Heads up before you launch it: see optional item 4 (R3-L3). As written, the wake in Optional (non-blocking)You can merge without these. They're follow-ups or your call. 2. [R3-L1] The post-write withdrawal is only pinned by case
Repro: dropping the block makes only Fix idea: rewrite 3. [R3-L2] Double click on a remembered chat with a rejected disk read blanks the view
Repro: throwaway test, double click, W1 ok, W2 ok, newest-page read rejected. View is My suggestion: don't patch it in this PR. It would be the 4th fix commit on the controller. Narrow the body sentence to the single-click case and track the double-click case in #1123. The likely fix later is to treat 4. [R3-L3]
Fix idea: also wake when 5. [R3-L4]
Fix idea: 6. [R3-L5] The total tie order has effects the body doesn't mention, and no hook test asserts the visible order
On the test side, Repro: with the old comparator mocked into that suite it stays 12/12 green. Adding Fix: add that assertion to both 7. [R3-L6] The A/B spec runs only half of the global setup guard
With Fix idea: call Nits
What checked out clean
Nice work on the redesign. Once the final-head A/B and T2 are in the body, I'd be happy to see this go in. |
|
Thanks @zach88 for the third pass. #1112 (comment) R1-M1. The body has carried the final-head evidence since the edit at 2026-10-15 23:53 UTC, about two hours before this review was posted, so I think the review read an earlier copy of the body. On
R3-L3. The draining case did not occur in that run. The stateless Host stayed Ready through phase L ( Could you re-check against the current body? |
Evenfire Reviewer — Round 3Reviewed head: Decision: Approve with follow-ups The unconfirmed-selection redesign, the total catalog tie order, the overlapping-cleanup skip and the relaunch-ratio gate hold on this head, and none of them regresses a closed finding. R1-M1 stays closed. What remains is accuracy in the PR body, not another code change. Must fix
Should fix
Verified
Validation gaps
Collateral auditNo regression from the ten commits since Follow-up
PR descriptionUpdate required. The A/B tables, the live L vacuity run and the T2 result are now reported at this head. Two sentences do not match the code: the tie-order sentence (R3-L1) and "the starting process is always closed" (R3-L2). Pre-existingPre-existing: none identified during the scoped review. Next actionCorrect the two body sentences. No further patch to Full review archive: — Review performed by Evenfire Reviewer |
…on intent A pending same-chat selection advances the selection intent revision but leaves the active switch request in place. When its catalog load fails, the selection effect returns without a switch, and the superseded click returns on its stale revision without confirming or withdrawing the mark. Send and load-older then matched the leftover mark and were refused silently on a blank view. The guards now also require the mark's revision to be current. Row 15 of the provisional-selection suite covers it for both Host kinds; without the revision check both cases fail (send dispatched 0 times). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Deep review at Major: a superseded switch leaves its unconfirmed selection behind, and send then fails silentlyImpact is high and likelihood is low. Dev accepts the same send. Where: Cause. Trigger:
Result. Chat A is blank, with Fix, verified locally. Clear an owned mark before the superseded return: if (selectionIntentRevisionRef.current !== selectionIntentRevision) {
if (ownsProvisionalSelection()) provisionalSelectionRef.current = null
return
}This is safe because every other owner has already moved on by then:
So the only owned mark that can reach this line comes from a Repro test:
|
…tted Case 16 opens a chat on another Host from the sidebar while the click's write is pending, then returns to the first chat while its catalog load fails. The superseded click must not leave a provisional mark that refuses send. Runs for chatllm and chatllm-stateless; removing the selection intent revision check turns it red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks @jozer-rami. Your scenario is real on Reproduction. I ran your repro through the real controller hook on both Hosts:
Why it is fixed. Why I did not add the clear on the superseded return. With the revision check the leftover mark is inert. The next Regression test.
|
…/desktop-sidebar-flicker Resync the stacked PR with the head of #1112, which added f1f8f72 (retire a provisional mark superseded by a newer selection intent) and e58825b (its test). No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * commit 'e58825bf4f45aed690b3883a627c7e1f4c9f1e83': test(desktop): pin that a superseded cross-Host open leaves send admitted fix(desktop): retire a provisional mark superseded by a newer selection intent
The sidebar now paints the local cache before the Host catalogs arrive, so a
chat hydrated from the server showed its placeholder title ("New Chat") until
the Host answered, which for a suspended stateless Host means the whole wake.
The server title only lived in the in-memory catalog merge.
The existing freshness write (chat:reconcileSessions -> upsertServerSessions)
now carries the server's own title, and the store writes it into entries that
are already in the index without moving updatedAt. Server-only sessions and
deleted chats are still never created. A chat with a pending local rename
does not send a title, so the optimistic title is not overwritten. The
cross-Host publication writes its Host's titles too, so a Host the user never
selects keeps them across restarts. The next catalog page overwrites the
stored value, so it follows server-side renames.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chat that exists only on the server enters the local index when the user opens it, through ChatStore.createChat, which stored 'New Chat'. The catalog page that carried its server title had already been reconciled before the chat was in the index, so the title reached the index only on the next launch's catalog load, and that launch still painted 'New Chat' from the cache first. upsertServerSessions now keeps the titles of server-only sessions in memory, and createChat uses (and drops) the remembered title for a new entry. A session reported without a title (a pending local rename) forgets the remembered one, so an outdated server title is never written. No entry is created for a server-only session, and a locally deleted chat is still refused by createChat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a chat with an empty local cache whose history starts at turn 1
replaced its title with the first user message, even when the index
already held the server's title. The first message now names the chat
only while it has no title yet ('New Chat'); the title stays ephemeral.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@claude Please do a closing review of this PR at head 1. Is every reviewer finding fixed, or tracked in a follow-up issue? Check each one against the code at
jozer-rami also mentioned lower-severity notes that were not posted: a memo entry evicted by New chat during the write window, two mutation survivors, and a stale memo count after a server backfill. The stale count is #1123 item 25. The other two are not tracked yet. Other follow-ups: #1109 (expanded list collapses on a transient short list), #1110 ( 2. Do the commits after zach88's approval on
Points to check:
Evidence on cc @zach88 @jozer-rami: these are the commits made after your reviews. Please take a look if you want to re-check them. |
|
Claude finished @alfredolopez80's task in 3m 53s —— View job Closing review @
|
A same-row click that reused the switch request revived a provisional mark whose selection intent had already been superseded. When that click's last-active write failed, the withdrawal cleared a message the user had sent in the meantime. The mark now exists only at the current selection revision: beginSelectionIntent retires it on every advance, and switchToChat passes a live mark on to the click that supersedes it as a new object. Effect-driven switches inherit nothing. The revision check in provisionalSelectionFor is no longer needed. Rows 17 and 18 cover the late click and the late effect switch, both Hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it settled A catalog page requested before a rename settled could write the old server title over the rename on disk: while the page was in flight, while it was served from the 5 s catalog cache, or while the rename's local write was still running (the pending entry was registered after that await). Catalog pages now carry the sequence number taken when their request was sent. A rename registers its pending entry before the local write and records a settle stamp when it settles. One predicate gates the disk write and the three in-memory merges: a pending rename, or a page issued at or before the settle stamp, keeps the local title. A failed local write hands the key back to the replaced rename only when that rename has not settled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three renames of one chat in a row, where the second and the third local writes both fail: the second fails while the third is current, so it was never marked settled, and the third's failure then put it back as the pending rename. It still had `sending: true`, so it never sent its PATCH, and the chat refused every server title until the next rename. A failed local write now always settles its own entry, unless it is the current entry and the rename it replaced is still unsettled; in that case the key goes back to that rename, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 7: R5-M1 and R6-M1 fixed, plus one defect found during the simplification passPushed Problems detected and fixed1. R5-M1 (Medium; regression of
2. R6-M1 (Medium; regression of
3. F9 (found by a YAGNI/KISS review of the two fixes above) — fixed in
Simplification review (YAGNI/KISS) of the two fixesThree read-only reviewers looked at reuse/efficiency, simplification/YAGNI and altitude/KISS. None found speculative machinery: every new piece of state traces to R5-M1, one of the three R6-M1 windows, or a named mutation. They also checked simpler alternatives for R6:
Only F9 was applied. The optional cleanups (test-helper reuse, a duplicated type, a no-op settle) were left out to keep the diff to the defects. Validation on
|
|
Claude finished @alfredolopez80's task in 5m 59s —— View job Round 7 re-validation @
|
|
T2 on the PR head The branch profile was recreated from scratch for this run, so the orchestrator ran a full bootstrap with every image built from HEAD. Earlier attempts on this head did not reach a verdict for reasons outside the PR: one died in NP-08 with exit 137 (#757), and three were refused at the preflight because my local launcher restarted the node runtime on a running profile just before it. The launcher now starts the profile only when its apiserver is down and waits for every deployment to be Ready before the preflight. The PR body now lists the R5/R6/F9 fixes ( 🤖 Generated with Claude Code |
#1112, which this branch was stacked on, merged into dev as 81c60e3. One conflict, in attemptRenameRpc (useChatListController.ts): #1112 (d62c072) settles a confirmed rename through settlePendingRename, which stamps the settle on the catalog clock so a page requested before it keeps the local title. That covers the case 2d95a41 handled by invalidating the Host's session catalog cache on rename confirmation (#1130), so the merge takes dev's version and drops that invalidation and its test T-1130-2. With dev's version alone, T-1130-2's title assertion passed; only its witness that a new catalog request was made failed. The invalidation on Host release (T-1130-1) stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Fixes four Desktop chat-loading problems introduced or made visible in v0.11 (#849).
What this PR fixes
chat:getIndexawaited the cleanup of every pending deleted chat before returning the local index, so with pending cleanups the sidebar stayed empty for seconds after launch (v0.11: p95 1.3–29.6 s in the benchmark below). The index is now returned at once and the cleanup runs in the background (426eb72da).Promise.all), so whilechatllm-statelesswas suspended and waking, thechatllmrows did not appear either. Each Host's sessions are now published as its own catalog arrives (b319b516e); the stateless Host keeps its lazy load.afterTurn = latest-1) unchanged (432a458c4,48fcd0b30).c010b9002), and a chat first opened from the catalog is stored with that title instead of "New Chat" (e4166fcd5). Opening a chat whose history starts at turn 1 no longer replaces a known server title with the first user message; that message names the chat only while it is still "New Chat" (b91528a57). A later server title still replaces it, and a pending local rename is never overwritten. The title written is the server's own; client-derived titles are still not persisted (spec 15 Phase A).Adjustments that keep existing behaviour
clearActiveChat, New chat (15e009561,f1f8f720e). The mark lives only at the revision that set it: a newer intent retires it, and a click on the same row takes it over as a new mark, so a superseded click's failed write cannot blank a message sent after it (bdccf042f). While unconfirmed, send and "Load older" return without effect and keep the draft. New chat restores the chat through a fresh switch when its create fails.941ee014d,16d3c570c).chat:getIndexcalls do not repeat the removal and the index save (5ab4cffff).setLastActiveChatIdskips the index write when the chat is already the last-active one (63e3a5f92).9f4e378e8). The same order applies to the selected Host's own list: tied rows follow chat id instead of index order, so after the active chat is deleted, the chat opened next is the lowest id among the remaining chats with the newest time.46c8dbea1). A local rename is registered as pending before its local write, and a catalog page requested before the rename settled (in flight, cached, or processed during the rename IPC) neither shows nor stores the old server title over it (d62c072bf). A superseded rename whose local write fails is settled, so it cannot block later server titles (1c24949dc).Not in this PR
'specific'selection) still clears the view while the target Host's catalog loads; that path, includingloadChatListreturningnull, belongs to spec 06 (d94532904removed the memo seeds there).Refs #1091
Refs #827 (step 1)
Recorded Desktop runs
Runs 1 and 2 use the PR's own Electron build on
e58825bf4, run 3 one4166fcd5, all against the branch profile with Playwright video and trace and one password login per run.Run 1: same-Host A → B → A and relaunch with pending cleanups
chatllmandchatllm-stateless, open chats A, B, C and A again. A MutationObserver on the return to A fails the test on any empty message list or "Loading conversation" spinner; none appeared (fix 3).chatllm): the first sidebar row appeared 249 ms after window load, before any click; reopening A shows the message sent in the first session (fix 1).Run 2: stateful and stateless Hosts while the stateless Host wakes
Launch 1 opened two chats per Host to fill the local cache. The HCC stateless idle window was lowered to 5 minutes for the run (restored to 30 afterwards, checked with
kubectl), and the run waited untilchatllm-statelesswas suspended (replicas=0, no pods; 5.9 min). Launch 2 then started with the same cache (PLAYWRIGHT_EXIT=0). Times are from window launch; the pod's Ready time is the KuberneteslastTransitionTime, truncated to the second.chatllmsidebar row, before any click (fix 1, fix 2)chatllm-statelessrows listed from the local cachechatllm-statelesspod Ready/models, 200)Every paint above happened before the stateless pod was Ready; the requests to that Host were still waiting for the wake. After the wake, stateless A was still shown in full.
Run 3: titles from the cache, then a server-only chat after the wake
Launch 1 opened two titled server chats A and B on
chatllm-statelessand checked that the local index on disk holds their server titles (fix 4). A second launch, on a copy of that data directory with the session restored (no second password login), created one new chat on the server; the original directory was then put back, so its cache does not know the new chat. With the idle window lowered as in run 2, the run waited untilchatllm-statelesswas suspended (6.2 min), and launch 2 started from the first cache (PLAYWRIGHT_EXIT=0). Times are from window launch.POST …/wake, 202)chatllm-statelesspod ReadyGET …/sessions, 200)No cached row disappeared before the server list arrived.
A/B results (head
62f62def4)BEFORE =
62de22cf3(C1: test ids only, v0.11 behavior), AFTER =62f62def4. Both builds run against the same branch profile and the same server chats.Two limits on what the A/B attributes to this PR:
devthan the PR's merge base (ebbd31544). AFTER also contains 211devcommits that BEFORE does not, 108 of them touchingdesktop-app/, including fix(desktop-ui): route immediate New chat sends to a fresh chat #976. Each gain is attributed to this PR by its vacuity run below: the memo disabled for O, the inline cleanupawaitrestored for L.1c24949dc. Since62f62def4it changes the provisional-selection guard used by send, "Load older" and New chat (f1f8f720e), adds one test (e58825bf4), stores server titles in the local index (c010b9002,e4166fcd5) and keeps a known server title when a chat is opened from turn 1 (b91528a57) and fixes two review findings in the provisional-selection mark and in rename/catalog ordering (bdccf042f,d62c072bf,1c24949dc), none of which the A/B measures. Values are p50 per Host. Phase O rounds are ordered BEFORE, AFTER, AFTER, BEFORE (ABBA). Phases L and W run every BEFORE round, then every AFTER round.Phase OL (HCC cadence as recorded, 22.7 min)
open_cached_ms(first open after launch, disk)open_cached_msopen_memo_ms(reopen in the same session)open_memo_msreopen_blank_witness(reopens showing the "Loading conversation" skeleton)reopen_blank_witnessrelaunch_rows_ms(window load → first stateful sidebar row, before any click; 10 pending cleanups)relaunch_ms(window load → row → explicit open → full transcript)A first open does not gain: it waits for the last-active write as #976 requires, so
open_cached_msstays within the budget of BEFORE rather than below it. The gain is on reopen and on relaunch.Each seeded chat has 4 messages (2 turns); the clock runs in the renderer from the click to the first frame that shows every message, checked once per animation frame (~16 ms resolution). The values compare the two builds under the same conditions; they are not an estimate of production latency. On reopen, the user-visible change is the blank skeleton going from 16 of 16 reopens to 0.
Phase W (stateless Host suspended, 13.9 min)
first_stateful_row_ms(max over AFTER rounds)first_stateful_row_ms(p50)Machine load (
loadavg_spread, reported, not gated)The machine (Apple M4 Pro, 14 cores) ran other branch profiles during the run. In phase O, ABBA ordering puts each build in the same load positions. Phases L and W are not interleaved; their ratios (L 0.052 and 0.095, W 0.13) are far larger than a load effect, and
round_validityinvalidated 0 rounds.Witness vacuity
maxChats: 0, local-only commit onba75871a8) the spec failed with 6 blank reopens onchatllmand 4 onchatllm-stateless;open_memo_msalone still passed, so the blank witness is what proves the memo.chat:getIndex(local-only37c6619aaon62f62def4):relaunch_rows_ms8427 → 5881 ms (ratio 0.698) andrelaunch_ms9204 → 6196 ms (ratio 0.673), so both gates fail, where the PR head gives 0.052 and 0.095.T2
make minikube-t2on the PR head1c24949dc, on a freshly created branch profile (full bootstrap, images built from HEAD):MINIKUBE_T2_PASS,T2_EXIT=0. T0, T1 and T2 pass; T1 626 tests in 82 files, Vitest exit 0.Benchmark (
chat:getIndexafter bind, idle machine)node scripts/bench-chat-open.mjson62f62def4, Node 24.18.0, Apple M4 Pro, 30 repetitions per row. v0.10.0 (d84385a62) reads the index directly, v0.11.0 (cfa72530f) awaits the deleted-chat cleanup and then reads, HEAD reads and starts the cleanup in the background. No Minikube cluster was running. Condition on every healthy row: HEADfirstGetIndexAfterBindp95 ≤ max(2 × v0.10.0 p95, 2 ms), evaluated on the JSON output. Result: 18 of 18 healthy rows pass. EACCES rows (a cleanup that fails on a read-only directory) are reported, not gated.Values in ms, p50 / p95. "Pending" is the number of queued deleted-chat cleanups per agent.
Tests
1c24949dc: 373 files, 4037 tests, exit 0. tsc (main and ui) exit 0,verify:electronexit 0, Node 24. fix(desktop-ui): route immediate New chat sends to a fresh chat #976's test files are unchanged, except for the shared__fixtures__/controllerHarness.tsx, which gains an additiveonRenderhook (3 lines).transcriptMemo.test.ts,switchToChat.transcriptMemo.test.tsx) and provisional selection (switchToChat.provisionalSelection.test.tsx, 24 tests), every case forchatllmandchatllm-stateless: A → B → A never renders an empty list, disk check and keep path, invalidations, write failures and double clicks, holds, deletes, New chat failure, and send / "Load older" guards.chatListPerHostPublication.test.tsx): Host authority epoch, tie order, offline rename budget per Host.chatStore.test.ts,chatListTitleMerge.test.tsx,renameSessionQueue.test.tsx): a cached "New Chat" takes the server title and the index on disk holds it; a chat first opened from the catalog is stored with the title that page reported, kept per agent and used once; a session sent without a title (pending rename) forgets the remembered one and never overwrites the local rename; a deleted chat stays deleted; a relaunch paints the stored title before any catalog resolves.switchToChat.hydrationTitle.test.tsx(both Hosts): opening a chat from turn 1 keeps its server title in the sidebar and the Latest list; an untitled chat is still named after its first message.chatListRenameCatalogOrder.test.tsx: a local rename survives a catalog page in flight, a cached page and a page processed during the rename IPC, on disk and in the sidebar, Latest list and selected list; a superseded rename whose local write fails is settled.chat:getIndex: returns while the cleanup is still pending; overlapping calls remove a tombstone once; a rejecting cleanup still returns the index (cleanupErrors === 1).node:test, wired into CI): 11/11.tools/e2e_static_audit.pyon the changed specs: exit 0.Known, not fixed here
useActivityController.tsmaps any failure of the initial activity import tonull(catch { return [agentName, null] }), so a read error looks like "no activity". Pre-existing.reconcileServerSessionscalls inuseChatListController.tsignore a failed write (.catch(() => undefined)and an emptycatch, fromf6abe0c8band879b862ba), so a failed title write is silent and the next catalog load retries it. Pre-existing.ChatThread.tsx:409-416), pre-existing.hasLocalMessagesBeyondhides read failures as "no older messages", pre-existing.🤖 Generated with Claude Code