Skip to content

fix(desktop): stop latest-session rows from flickering on reload, and related sidebar fixes - #1131

Closed
alfredolopez80 wants to merge 77 commits into
devfrom
fix/desktop-sidebar-flicker
Closed

alfredolopez80 wants to merge 77 commits into
devfrom
fix/desktop-sidebar-flicker

Conversation

@alfredolopez80

@alfredolopez80 alfredolopez80 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

fix(desktop): stop latest-session rows from flickering on reload, and related sidebar fixes

Base: dev. This branch was stacked on #1112, which merged into dev as 81c60e35a; dev is merged in at 046e2894e, at fab73b054 (57a86a470), at f92106d69 (ca5f0c91f), at 06fc32acb (bffb8f372), at 523f328e0 (ce1b21abf) and at d2e4cc57c (899a76016). Closes #1109, closes #1110, closes #1130, closes #1152. Refs #1123 (item 16). Related: #849, #925, #941.

Since the merge of dev at 523f328e0, the diff against dev touches only desktop-app/ (36 files). The two commits outside the Desktop code, fb1cb2a96 (Minikube T1 private-suite exclusion) and 154442d00 (mcp-host fixture copy for Node 24.22), stay in the branch history, but dev now has the same fixes (40e94910b, 9b9aaefe1), so they no longer appear in the diff (R2-L3, R3-L3).

Summary

Commit Change
74ca2d0d6 hold is idempotent for the same kind (H2): a repeated hold still advances the epoch but no longer re-runs blockHostAccess (catalog refresh, selection clear).
16f30000b Both chat auto-selections skip held Hosts (H1): ChatPage and the no-focused-chat branch of handleNavSelect.
a1d6a4d9c Clicking Chat on the Chat route keeps the focused chat's agent selected (H16).
57f03ac42 A held Host is listed in Latest sessions with a Retry button; a failed verification and the loss of the selected Host are reported (F1–F3).
00ae473d5 A reload keeps already published remote latest-session rows until their Host's catalog arrives (H5). This removes the visible flicker.
ac4d2f379 The agent sessions card stays expanded until the agent changes (H10, #1109).
4033b4c20 A failed read of older local history is logged and shown instead of hiding "Load older" (H11, #1110).
2d95a4180 The session catalog cache is invalidated when a hold is released (#1130). Its invalidation on rename confirmation was dropped in the merge of dev; see the #1130 section.
9edf68d26 Two comments from this branch now match the code. No behaviour change.
2033c8616 Retry on a held Host closes the mobile menu, and each Retry button is named after its Host (review findings on F1).
ff7899b18 The five sidebar navigation controls share one close step that also moves keyboard focus to a visible control; Retry closes an open session options menu. The narrow-window test stubs no longer leak into other tests (sign-off findings).
8df66b51d The narrow-window backdrop uses the same close step, so it also returns focus to the menu button; T-D15-4 witnesses the focus effect in the same test (commit-11 review findings).
0a5285724 The team-switch teardown (resetChat) drops remote latest-session rows. Before this, the commit that made the new team current still showed the previous team's remote rows for one commit, because the scope effect that strips them runs after that commit (Evenfire Reviewer R1-H1; T-R1H1-1, T-R1H1-2).
cdc29b6c2 While the active chat tab's Host is held, ChatPage does not auto-select another Host, so the focused chat tab stays until Retry or a Chat click verifies the Host (review 5547292745; T-J1-1, T-J1-2, and an App wiring test in App.chatDrawer.test.tsx). 87212376f and ce9e43790 extended the guard to a Host that left the catalog; 44531cd74 returns it to held Hosts only, and 2d9fed46b to held Hosts that still have a Retry row; see "Keeping the chat tab".
046e2894e Merge of dev (81c60e35a). One conflict, in attemptRenameRpc, resolved with dev's version (see #1130).
fab73b054 Merge of dev (57a86a470). No conflicts.
b8b1232d5 The per-Host publication test asserts the visible order of two same-time rows from different Hosts, in both catalog arrival orders (#1123 item 16). Test-only.
87212376f A catalog refresh that drops the selected, non-held Host clears the selection with <name> is no longer available. (name from the previous catalog of the same user and team). Found by the real-app E2E (F3a, F3b). The deferred uncertain-hold toast and the absent-Host guard in ChatPage that this commit also added were removed by 44531cd74.
f8a9daff1, ad8d013fe, 3ab0d8761 Fixes and tests for the deferred toast of 87212376f (A9 review, external review r3). Superseded: 44531cd74 removed the deferred toast, so the cases they covered no longer exist.
f92106d69 Merge of dev (ca5f0c91f). No conflicts.
ce9e43790 After a team switch, ChatPage auto-selects again when the open tab's Host is not in the new team (R1-H1; T-R1H1-T stays as its regression test). Its per-hold-episode toast fix (R1-M1) was removed with the deferred toast in 44531cd74. Review 6271309203.
6e947f319 resetChat discards in-flight list reads of the previous scope at once, so a previous-team page that settles during the switch cannot reach the new team's first commit (R1-M2).
6b25f9a1f Tests only: a published remote row of a Host that leaves the catalog is dropped (R1-M3); one file-level reset of the SidebarNav test context (R1-L8).
06fc32acb Merge of dev (bffb8f372, #1056). No conflicts in this PR's files.
154442d00 Tests only, mcp-host: loadDevStore copies the dev GFS store fixture entry by entry. Node 24.22.0 (nodejs/node#64124) makes fs.cpSync with errorOnExist throw when the destination directory exists, which broke 11 mcp-host tests on the CI runner. dev now has the same fix (9b9aaefe1), so it is no longer in the diff.
44531cd74 Simpler held-Host handling. An uncertain hold of the selected Host deselects it and shows Could not verify access to <name>. at once. ChatPage keeps the focused chat tab only while its Host is held. The Retry row names each held Host by its display name (#1152). This removes the deferred toast, HostAuthorityStore.heldSince and holdKind(), the catalog-returning refresh(), the pending-toast marker and the per-team listed-Hosts set.
fb1cb2a96 Harness, outside the Desktop code: Minikube T1 excludes the two private password-recovery suites added by #1056, reading the list from tests/contracts/password-recovery-suites.json as public CI does. Without it T1 refuses the run with ZERO_TESTS_EXECUTED (3 pending tests). Cherry-picked from #1047 (e842b4427). dev now has the same fix (40e94910b), so it is no longer in the diff.
2d67ff2a7, 555ed4804, d827635de, 2a150797e Tests and comments only, review 6278200614: the App wiring test also checks heldHosts (R2-M2); the SidebarNav context mock carries heldAgentRefs (R2-L5); H5 test comments state the reason instead of a spec section (R1-L7); T-A9-P1-4 is restored with the immediate toast (R2-L2).
2d9fed46b The focused chat tab is kept only while its Host is held and still in the catalog, that is, while it has a Retry row. A held Host on the focused tab that leaves the catalog gets <name> is no longer available. (name from the previous catalog), and ChatPage selects a listed, non-held Host (R2-M1; T-A9-P2).
142880262 A revocation of the Host on the focused chat tab is announced with Access to <name> was revoked. even when an earlier uncertain hold already cleared the selection (R2-L1; T-R2L1-1, T-F3-2).
44013d259 Test only: a new hold after a successful Retry is announced again (R2-L2; T-R2L2-1).
9c326b615 Test only: a carried remote row whose session is now also in the local index is published once per commit (R3-M1; T-R3M1-1).
d477e26f8 Comments only: the scope-invalidation comments name the reads they cover (the catalog and selected-list reads), and two test comments drop their ticket labels (R3-L1, R3-L2). It also carries a Prettier reflow of useAgentChatController.ts:3677-3679, formatting only (R4-L6).
11b12c476 No chat is focused while the Apps picker is open, so keepFocusedChat does not hold a tab that is not on screen; the keepFocusedChat comment in NavigationContext.tsx states the current rule (A22 review). It also silenced the loss and revocation toasts behind the picker; e0e470707 and 5fd232e07 reverse that part.
29416e8d1 Test only: T-R3M1-1 also requires a remote-only row of the same Host to stay carried (A22 review).
e0e470707 Every held Host that leaves the catalog of the same user and team is announced with <name> is no longer available. once, at the catalog change, whatever is focused. Returning to Chat does not repeat it (R4-M1; T-A23-1, T-R4M1-2).
5fd232e07 A revocation of the Host of the stored active chat tab is announced even while the Apps picker is open over it. The picker still clears focusedChatAgentRef for keepFocusedChat (R4-M1; T-A23-2).
762bc9607 Test only: a revocation followed by the catalog drop of the same Host shows both toasts, in that order, and no third one (R4-L1; T-R4L1-1).
0e248b3c5 Test only: the held-Host test helpers wait for the boot's post-paint catalog refresh before taking a refresh-count baseline. Before, about one run in six counted that refresh.
7346d8bf4 A failed verification of a Host that is no longer in the catalog names it by its last known display name, not by its ref. The names are cleared with the authority scope (R4-L2; T-R4L2-1).
11146a14e Comments only: the invalidateAuthorityScope comment names every continuation the scope generations cut, including the tails of create, rename and delete (R4-L5); the T-F3-1 title regains its space (R4-L6).
0720afb48 The last-known-name map takes names only from a catalog of the current user and team, and the failure toast reads only that map, so a verification pending across a rename uses the new name (A24 review R4-ASTRA-1, R4-ASTRA-3; T-R4L2-2, T-R4L2-3).
c96aeff2e The Host authority scope is keyed by the session team (me.teamId, trimmed as the main process trims it), not by the directory team. Before, when me.teamId was empty, the team directory arriving after paint changed the scope with no principal change and cleared the holds and the last known names, so a failed Retry of a Host still in the catalog said Could not verify access to <ref>. (R5-L1; T-R5L1-1).
db6094475 Held-Host labels and hold toasts take a Host's name only from a catalog of the current user and team, then the last known name, then the ref. Before, during a team switch the Retry row and the hold toast could show the previous team's name for the same ref (R5-L3; T-R5L3-1, T-R5L1-2).
acac80bcf Every hold toast resolves the name the same way, so a revocation that arrives after the Host left the catalog says Access to <last known name> was revoked. instead of the ref (R5-L2; T-R5L2-1).
7fae3f6e9 The catalog effect ignores a catalog of another user or team: a pre-switch refresh that lands after a team switch no longer announces <name> is no longer available. nor deselects a Host of the new team (R5-L4; T-R5L4-1, T-R5L4-2).
eefce7b6e A Host held while it is selected in the chat drawer keeps the drawer on its blank landing until the user selects another Host, as the full-screen chat tab already did. Before, the drawer's ChatPage auto-selected another Host right after the hold, because the drawer is never the stored active tab (R5-M1; T-R5M1-1..4).
27e3eaa33 Tests only: the Host-name tests use producer-shaped catalogs (a display name for every listed ref, a distinct name for the same ref in team 2) and assert the name inside the team-switch window (R5-M2; T-R4L2-1/2/3 rebuilt).
6f1313342 Comment only: the team clause of the catalog gate gives its reason, the trimmed session team that keys the catalog (R5-L6).
d502a829a Test only: the chat cache scope witness in useAppController.teamContext.test.tsx expects the trimmed session team that c96aeff2e introduced (R5-L1 follow-up).
b8ee26894 Test only: T-R5L1-1 places the hold before the team directory arrives and asserts it held before and after, so the mutation "scope back to the directory team" turns it red (review R5-ASTRA-2).
c2584cc27 Held Hosts, the Retry rows, ChatPage auto-select and the Chat nav pick read one catalog: the cached one only when it belongs to the current user and team, else none. During a team switch the previous team's cached catalog no longer lets ChatPage or the Chat nav pick a Host of the previous team, and the first render of a new principal shows no Retry row from the previous scope (R5-ASTRA-1, R5-ASTRA-3; T-R5A1-1a/b, T-R5A3-1).
f458ac18f The catalog effect returns on a catalog of another principal before it records that catalog as the loss baseline, so a late foreign catalog cannot hide the later loss of a held Host (regression review; T-R5G612-1, T-A9-T1 extended).
64b56a5b6 The drawer keep starts only for a hold made while the drawer was open (also while the window width hides it). Before, a hold made in Agents with no chat on screen kept a later drawer on a blank landing (regression review; T-R5F1-1, T-R5F1-2).
9498bc661 Opening the Apps picker over Files no longer ends the drawer keep (regression review; T-R5F2-1).
523f328e0 Merge of dev (ce1b21abf). The conflicts were in the mcp-host fixture and the Minikube T1 scripts, and were resolved with dev's version, which already has both fixes. The dev test App.activationSeam.test.tsx gains the controller field this branch adds to its mock (fixture only).
6017ac9a1 A held Host that the drawer restores from its last chat tab stays on screen while it is verified, whether the verification succeeds or is denied. Before, ChatPage selected another Host during the verification and the restore was discarded (A29 diff review).
d57b3fb90, 961901294 Tests only: each no-toast test (T-F3-2, T-R5L4-1/2) gains a positive phase with the same emitter, so it fails when the toast code does not run; the helpers are typed for the renderer tsc (A29 diff review).
2692891d5 A denied request for another held Host from the drawer does not end the drawer keep (A31 diff review; T-A31SOL1-1). Its guard was replaced by 9997d58b4.
9997d58b4 The drawer's Host is the Host of the last chat selection, or of a drawer request for a held Host, recorded when the selection happens. This replaces the interrupted-Host marker that the hold set and the drawer-open flag App wrote for it. A hold made in Agents after an earlier chat selection of that Host now keeps the drawer on it, as the last-tab restore already does (A32 diff review; T-A32SOL2-1, T-A33-F1).
f550f0fe8, 043470733 Closing the last chat tab of the drawer's Host forgets it, so a later drawer with nothing to restore selects a listed Host (A33 diff review; T-A34-1, T-A33-F1 positive phase). The second commit is comments only.
4a1a82dda Retry on a held Host returns to the conversation on screen (the focused chat tab, or the drawer's last chat tab of that Host) instead of a blank chat (blind review; T-A36R1-1).
8348f9df4 A chat opened from a notification records its Host as the drawer's Host, so a hold of that Host keeps the drawer on it (blind review; T-A36R2-1).
22b1d559d Test only: the scope-change test counts every Could not verify access to toast after the switch, so removing the scope guard turns it red (blind review).
e8615977e Comments only: three held-Host comments match the code (blind review).
d2e4cc57c Merge of dev (899a76016). No conflicts in this PR's files.

What this PR claims about the flicker cause (step 0)

The flicker is visible only when Latest sessions contains remote: true rows, that is, sessions created on another machine. The user who reported it runs two clients (macOS and Debian).

  • Step 0 on the reporter's machine: pending. It runs on their Mac with the production v0.11 build while the sidebar flickers: Settings with the chat drawer closed (falsifier), 30 s of main-process log, and CDP logpoints on the minified bundle.
  • Single-client recording (2026-10-15, plan author's machine, same v0.11 build index-BWtzYAD0.js): 4 minutes of use with 10 logpoints attached recorded 0 holds, 0 verifications, 0 Latest-sessions reloads and 0 401/403 errors in the main log (counts={}). A 3-minute witness recording on the same logpoints captured 5 nav and 3 autoselect events matching the user's clicks, so the logpoints were live. On that account the H1 trigger did not appear. This neither confirms nor rules out H1 for the reporter, because the trigger may depend on their account or team (host access: return a machine-readable revocation code on the RPC token mint 403 #941).

So this PR does not claim that H1 causes the flicker. H1 is a defect that can produce it:

  1. A read on a Host gets a 401/403; blockHostAccess holds it, clears the selection and refreshes the agent catalog.
  2. With no selection, ChatPage picks with pickLatestAgent, which ignores holds, and can pick the held Host again.
  3. handleSelectChatAgent verifies the held Host with an uncached listSessions (limit: 1). If it succeeds, the Host is released, the release detector bumps latestReloadRevision, and the cross-agent loader runs again. Its local-only publication is the flicker (H5).
  4. If the next read on that Host fails again, the cycle restarts.

The loop needs three conditions that have not been observed in production: a failing read, a succeeding limit: 1 verification right after it, and the held Host being the one pickLatestAgent picks. The Vitest tests inject the trigger (T-H1-3), so they prove the mechanism, not that the trigger occurs in production.

H5 removes the flicker of rows of Hosts that stay in the list on any same-scope reload, whatever starts the reload. H1 and H2 are fixed regardless of the trigger: an auto-selection must not pick a held Host or start verifications without a user action, and a repeated hold must not repeat the refresh.

H5 does not cover two cases: a catalog refresh during a temporary team switch can drop a non-held Host from agentNames (its rows leave and return; handled in Backend-1), and a non-authorization catalog failure removes that Host's remote rows on purpose, as today.

Why the #849 holds stay

Opus 5.5 and GPT-6 Astra reviewed separately whether to remove the host holds; both concluded keep-with-changes.

The actual hiding contract. The message of 54c4919dd says "hide only on confirmed revocation", but the code also hides uncertain holds: visibleLatestChatSessions filters every held Host (useChatListController.ts:1668-1678 at ba75871a8). This PR keeps that contract and makes it visible (F1).

Visible changes (F1–F3)

Fixing H1 removes an accidental recovery: today the auto-selection re-picks a held Host and its verification releases it. After this PR nothing re-checks a held Host until the user selects it, opens one of its notifications, or the scope changes. Three consequences, handled as the user chose:

  • F1. Latest sessions shows one row per held Host that the catalog still lists, before the session list: Couldn't verify access to <name> with a Retry button, where <name> is the Host's display name, as in the toasts (Desktop: the Retry row names the Host by agentRef while the toast that points to it uses the display name #1152). It also renders while the list is empty or loading. Retry does what a click in the agent selector does (handleSelectChatAgent(agentRef, { selectLatest: false })) and closes the sidebar menus, as the session rows do. Each button's accessible name is Retry access to <name>. Display names are not guaranteed distinct, so two held Hosts with the same display name get the same row text and button name. The toasts and the agent switcher (AgentWorkspace.tsx:286) use that same name and do not tell them apart either (R2-M3, closed in writing).

  • Focus. On a window 900 px wide or less the sidebar is an overlay menu. The five controls that navigate from it (nav items, session rows, app rows, Retry, logout) and the backdrop that closes it now share one close step: it closes the navigation, Settings and session options menus, and when the overlay was open it moves focus to the logo button that reopens it. In a wide window, a successful Retry removes its row with the focused button; focus then moves to the Chat item, unless the user has already moved it elsewhere.

  • F2. A failed verification logs console.warn('[host-authority] access verification failed', { agentRef, status }) with the HTTP status only (the error message carries a response excerpt that is not scrubbed), and shows the toast Could not verify access to <name>. when the scope did not change.

  • F3. One toast per event. A revocation followed by the catalog drop of the same Host is two events and shows both toasts, in that order (R4-L1, pinned by T-R4L1-1):

    • A confirmed revocation of the selected Host deselects it and says Access to <name> was revoked.
    • An uncertain hold of the selected Host deselects it and says Could not verify access to <name>. The toast makes no Retry promise, because the hold's catalog refresh can drop the Host, and the Retry row lists only Hosts the catalog still has.
    • A revocation of the Host of the stored active chat tab says Access to <name> was revoked. even when the Host is no longer selected (for example after an uncertain hold of the same Host, R2-L1), and also while the Apps picker is open over that tab (R4-M1).
    • When a catalog refresh drops the selected Host while it is not held, the selection is cleared and the toast says <name> is no longer available., with the name from the previous catalog of the same user and team.
    • Every held Host that leaves the catalog of the same user and team gets the same toast once, at the catalog change, whatever has focus: its chat tab, the Apps picker, Files, another chat, or no tab. Returning to Chat does not repeat it (R2-M1, R4-M1).
    • Every toast that names a Host resolves the name the same way: the display name from the current catalog when that catalog belongs to the current user and team, else the last known name from such a catalog, else the ref. A failed verification or a revocation of a Host that is no longer in the catalog therefore names it by its last known display name, and a team switch never shows the previous team's name for the same ref (R4-L2, R5-L2, R5-L3). The loss toast keeps the name from the previous catalog, which is the current principal's (R5-L4).
    • A catalog of another user or team that lands late (a refresh started before a team switch) does nothing in the controller: no loss toast, no deselection, and it does not become the baseline for the next loss (R5-L4). Holds, Retry rows, ChatPage auto-select and the Chat nav pick read the same current-principal catalog, so while such a catalog is the cached one none of them lists or picks its Hosts (R5-ASTRA-1, R5-ASTRA-3). Display surfaces (the agent switcher, Fleet, the header, the Agents page) still list the cached catalog; see "Not in this PR".
    • The authority scope is keyed by the session team, so the team directory arriving after paint does not reset the holds or the last known names (R5-L1).
    • A team or login change shows no toast. No other hold shows a toast: neither an uncertain hold of a Host that is not selected, nor a revocation of a Host that is neither selected nor the Host of the stored active chat tab. If that Host later leaves the catalog, the loss toast announces it.
  • Keeping the chat. While the Host the user was on is held and still in the catalog, that is, while it has a Retry row, ChatPage does not auto-select another Host, so the next message cannot go to a different Host. What stays is the chat surface: the selection is cleared, so useAgentChatController clears activeChatId and the messages until Retry or a Chat click verifies the Host. On the full-screen chat tab the kept Host is the tab's Host. The chat drawer (over Files, Settings or an app) is never the stored active tab, so the controller records the Host of the conversation it shows: the Host of the last chat selection, of a drawer request for a held Host, or of a chat opened from a notification. The record ends with the next chat selection, with a team or login change, or when the user closes the last chat tab of that Host. While that Host is held and listed, the drawer stays on its blank landing and selects no other Host (R5-M1, A32-SOL-2, A33-SOL-P2, A36-R2). A successful Retry returns to the conversation on screen, the focused chat tab's or the drawer's, instead of opening a blank chat for the Host (A36-R1). When the held Host leaves the catalog, its Retry row goes with it and nothing is kept: ChatPage selects a listed, non-held Host. Holds reset with the authority scope, so after a team switch no Host is held and ChatPage auto-selects a Host of the new team. While the Apps picker is open no chat is focused, so keepFocusedChat keeps nothing; the picker affects only this rule, not the toasts.

    "Tab" is the stored active chat tab, and "Picker" the Apps picker open over it. "Drawer" is the chat drawer over Files, Settings or an app. "Elsewhere" is Files, another chat, or no tab. "Same principal" means a catalog of the current user and team.

    # Host state → event Tab / Picker Drawer Elsewhere Toast
    1 not held, selected → leaves the catalog (same principal) deselected; ChatPage picks a listed Host same deselected <previous name> is no longer available.
    2 held → leaves the catalog (same principal) its Retry row goes; ChatPage picks a listed, non-held Host same same <previous name> is no longer available. once, at the catalog change; not repeated on return
    3 a catalog of another principal lands late (a refresh started before a team switch) nothing: holds, Retry rows, ChatPage auto-select and the Chat nav pick ignore it (R5-ASTRA-1); display surfaces still list it ("Not in this PR") same nothing none
    4 unheld → uncertain, selected deselected, tab kept, no other Host selected deselected, drawer stays on the blank landing, no other Host selected deselected; a drawer opened later keeps it only if it is the Host of the drawer's conversation (row 10), else the next chat surface selects a listed Host Could not verify access to <name>.
    5 → revoked, selected as row 4 as row 4 as row 4 Access to <name> was revoked.
    6 → revoked, not selected, Host of the stored active chat tab unchanged — — Access to <name> was revoked.
    7 → held, neither selected nor the stored active chat tab's Host (includes the drawer after row 4) unchanged unchanged unchanged none; if it later leaves the catalog, row 2 announces it
    8 revoked (row 5 or 6) → then leaves the catalog row 5/6, then row 2 row 5, then row 2 row 2 both toasts, in that order
    9 verification fails for a Host absent from the catalog the tab stays in the strip; the selection moves to a listed Host the selection moves to a listed Host — Could not verify access to <last known name>.
    10 keepFocusedChat while the tab's Host is held and listed; false under the picker while the Host of the drawer's conversation (last chat selection, drawer request for a held Host, or notification open) is held and listed, until the next selection or until the user closes the last chat tab of that Host can be true over Files, Settings or an app with the drawer closed; no ChatPage reads it until the drawer opens —
    11 leaves the catalog → then revoked unchanged unchanged unchanged Access to <last known name> was revoked. when row 5 or 6 applies, else none
    12 only the team directory changes (it arrives after paint) nothing nothing nothing none; holds and last known names survive

    Recorded, not changed: in the drawer, New chat after a hold leaves the blank landing as it is (with no Host selected it has nothing to open). Reopening the drawer restores its last chat tab through handleSelectChatAgent, which for a held Host is a verification, like Retry: it ends the keep if it succeeds and keeps the drawer on that Host if it fails.

If #941 makes every Host return 403, no Host is selected and each one gets a Retry row; every failed Retry shows a toast. The app does not retry on its own.

H16: Chat opens the most recent agent

Found by the step 0 recording: at 17:03:36 UTC a click on Chat while in an alfredo-dev chat produced nav → select alfredo-dev fastPath:true → 4 ms later autoselect pick=pr-reviewer → select pr-reviewer chatId=null.

  • nav.handleNavSelect('chat') clears the selection and returns the focused chat; handleNavSelect calls handleSelectChatAgent with it; on the Chat route with that agent selected, selectChatAgent takes the fast path, which loads the chat but never sets selectedAgent again. With the selection empty, ChatPage auto-selects the most recent agent and opens a "New chat" tab for it.
  • Origin: fc3217d04 (2026-09-17, v0.10) made handleNavSelect load the focused chat through handleSelectChatAgent; the fast path (7828e0fb4) is older. Each piece is correct alone.
  • Fix: the fast path calls nav.setSelectedAgent(agentName) in the same batch, so ChatPage never sees the empty selection. On every other fast-path caller it is a no-op.

H11 (#1110): trade-off

hasLocalMessagesBeyond now logs [chat-history] failed to probe older local messages and returns true instead of swallowing the error, so "Load older" stays visible. handleLoadOlderMessages no longer turns a failed local page read into []; the outer catch logs it and shows Could not load older messages. if the chat is still the one in view.

Accepted cost: today, when an older local page cannot be read in a stateful chat with server turns, loading silently continues from the server. After this PR that click shows the toast and loads nothing. A corrupt local copy is a defect the user or support must see; a silent fallback would hide it.

Two consequences of where the change sits, both intended:

  • The toast is in the outer catch of handleLoadOlderMessages, so it also appears when the server backfill or replaceMessages fails in that click, not only when the local page read fails. After a replaceMessages failure the merged page is already on screen.
  • The probe returns true for any rejection of its local read, including a failed index read or legacy migration inside loadMessages. "Load older" stays, and the next click reports the error.

#1130: the session catalog cache

The renderer shares one listSessions request per Host and query for five seconds. Two events change what a cached page means, and neither dropped it:

  • Releasing a hold. A catalog read can enter the cache while its Host is held: the cross-agent loader's read starts before the hold, fails with a generic 403 after it, and readSessionCatalog retries with force. The first reader after verifyHostAccess released the Host shared that request; when it was rejected as revoked, the Host was held again right after it had been verified. verifyHostAccess now invalidates the Host's cached requests after a successful release, as blockHostAccess already does when it holds.
  • Confirming a rename. Once a rename is confirmed its pending marker is removed and the server title wins the merge. A reload inside the cache window merged a page read before the rename, so the old title came back until the entry expired. 2d95a4180 invalidated the Host's cached requests where the marker is removed. perf(desktop): reduce chat-open latency and keep reopened transcripts in memory #1112 fixed the same case in dev (d62c072bf): settlePendingRename records when the rename settled on the catalog clock, and a page requested before that, cached or in flight, keeps the local title. The merge of dev takes that version and drops this PR's 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 TTL is unchanged. A hold alone does not re-run the cross-agent loader (isHostAccessBlocked is a stable callback), so T-1130-1 uses the forced-retry path to put a during-hold request in the cache.

Pre-existing fallbacks found, not changed here

  • Reconciliation turns a failed read into [] with a warning (useAgentChatController.ts:1795-1801, :2174-2180 at ba75871a8).
  • The cross-agent loader's local index read turns a failure into [] with no warning (useChatListController.ts:1514-1520 at ba75871a8).
  • The reuse branch of switchToChat recomputes hasOlderMessages from cached.length; after a "Load older" that value is no longer the number of loaded local messages, so the button can hide while history remains. Reported separately.

Tests

The repository tests are Vitest. The real-app E2E in the next section runs outside the repository. Each claim is tested at the level where the defect lives. "Base" means the new and modified tests run against the base production code: 48fcd0b30 for the rows up to T-925, re-checked on 45f1584c1 after the first rebase, and 45f1584c1 for T-1130.

Id File Level Base
T-H2-1, T-H2-2 ui/src/lib/__tests__/hostAuthorityStore.test.ts store red (defect assertion) / green (contract)
T-H1-1, T-H1-2 ui/src/pages/__tests__/ChatPage.autoSelect.test.tsx jsdom, mocked context red
T-H1-3, T-H1-4 ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx controller, observed commits, injected 403 red
T-H16-1 ui/src/hooks/__tests__/useAppController.workspaceTabs.test.tsx controller, observed commits red
T-F1-1 (×3) ui/src/components/__tests__/SidebarNav.test.tsx jsdom red
T-D13-1 (two held Hosts), T-D13-2 (mobile menu) same jsdom red (base 9edf68d26)
T-D15-1 (focus to the menu button), T-D15-2 (session options menu), T-D15-3 (focus to Chat after a verified Retry) same jsdom red (base 2033c8616)
T-D15-4 (focus moved by the user is kept; its first phase witnesses the focus effect), T-D13-1 visible text Retry same jsdom green (contract)
T-D17-1 (the backdrop returns focus to the menu button) same jsdom red (base ff7899b18)
T-F2-1, T-F2-2, T-F3-1 (×2), T-F3-2 ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx controller, injected 403 red
T-H5-1, T-H5-4 ui/src/hooks/domain/__tests__/chatListPerHostPublication.test.tsx controller, observed commits red
T-H5-3, T-H5-5 same same green (contract)
T-1123-16 (cross-Host tie order, both arrival orders) same same green (contract); removing the Host/id tie-break from byUpdatedDesc turns one case red
T-H10-1, T-H10-2 ui/src/components/agents/__tests__/ChatThread.sessionList.test.tsx jsdom red
T-H10-3 same jsdom green (contract)
T-H11-1, T-H11-2, T-H11-3 ui/src/hooks/domain/__tests__/switchToChat.unified.test.tsx controller, rejection injected in the loadMessages double red
T-925-M1 ui/src/hooks/domain/__tests__/chatListTitleMerge.test.tsx (existing, witness added) controller green
T-925-M2 src/__tests__/chatStore.test.ts (existing, witness added) main-process store green
T-925-M3 ui/src/hooks/domain/__tests__/useAgentChatController.crossChat.test.tsx (existing, upsertMessages write witness added) controller green
T-1130-1 (a), (b) ui/src/hooks/__tests__/useAppController.hostHoldCatalogCache.test.tsx controller, injected generic 403 then revocation red
T-J1-1, T-J1-2 ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx controller and ChatPage, injected 403 / Chat click red
App wiring of selectableAgentRefs, heldHosts and keepFocusedChat ui/src/__tests__/App.chatDrawer.test.tsx App with a controller stub red under each mutation (heldHosts: [] gives Received "[]", R2-M2)
App wiring of forgetClosedChatHost (called once per tab close with the closed tab and the remaining tabs) ui/src/__tests__/App.activationSeam.test.tsx App with a controller stub red when App does not call it
T-A9-O1, T-A9-O2 (selected Host dropped by a refresh) ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx controller, injected 403 red (base b8b1232d5)
T-A9-P2 (a held Host on the chat tab leaves the catalog: toasts exactly Could not verify access to <name>. then <name> is no longer available., and another Host is selected) same controller and ChatPage, injected 403 red (base 2a150797e: expected null to be 'agent-b')
T-R2L1-1 (two in-flight transcript reads of the chat tab Host, a generic 403 then host_access_revoked: toasts exactly the uncertain toast then Access to <name> was revoked.) same same red (base 2a150797e)
T-F3-2 (×2, host_access_denied and host_access_revoked: a Host neither selected nor on the chat tab gives no toast) same controller, injected 403 green (contract); the revoked case is red when every revocation toasts
T-R2L2-1 (hold, successful Retry, hold again: two toasts) same same green (contract); red when blockHostAccess toasts once per Host per mount
T-A23-1 (×2, Apps picker open over A's tab / Files tab: held A leaves the catalog; toasts exactly Could not verify access to <name>. then <name> is no longer available., no Retry row; returning to Chat selects B once and adds no toast) same controller and ChatPage red (base 29416e8d1: the loss toast is missing)
T-A23-2 (Apps picker open over A's tab, held A is revoked: toasts exactly the uncertain toast then Access to <name> was revoked.; focusedChatAgentRef stays null) same same red (base e0e470707)
T-R4M1-2 (×2, uncertain and revoked: a background held Host, neither selected nor on a tab, leaves the catalog; toasts exactly its loss toast, the selected Host is kept) same controller red (base 29416e8d1)
T-R4L1-1 (selected A revoked, then dropped by the catalog: toasts exactly the revocation then the loss; a navigation re-run adds none) same same green (contract pin); red when either toast branch is removed
T-R4L2-1 (held A drops out of the catalog, a further refresh, then a failed verification: the toast uses the last known name; after a team switch the old team's name is not used) same same red (base 0e248b3c5: Could not verify access to agent-a.)
T-R4L2-2 (team switch with the old team's catalog still cached, navigation in that window: names are taken only from the new team's catalog) same same red (base 11146a14e)
T-R4L2-3 (a verification pending across a rename: the toast uses the new name) same same red (base 11146a14e)
T-R4L2-1/2/3, rebuilt (R5-M2): every catalog carries a display name for each listed ref, team 2 names the same ref Team Two Host, and the name that pins the scope clear and the principal gate is asserted inside the switch window, before team 2's catalog lands same same red under the mutations in the round-5 table
T-R5L1-1 (×2, me.teamId '' and 'team-1': A held before the team directory arrives after paint stays held, and a failed Retry says Could not verify access to Research Host.) same controller, directory delayed '': red (base 0720afb48); 'team-1': green (contract, the directory team equals the session team)
T-R5L1-2 (me.teamId ' team-1 ': the Retry label is the display name) same controller green (contract pin); red when the trim is dropped
T-R5L3-1 (a hold during a team switch, with team 2's catalog pending, never shows team 1's name for the same ref, in the label or the toast) same same red (base c96aeff2e)
T-R5L2-1 (Apps picker over A, A leaves, then a late host_access_revoked: toasts exactly the uncertain toast, the loss toast and Access to Research Host was revoked.) same controller and ChatPage red (base db6094475)
T-R5L4-1 (a pre-switch refresh that lands after the team switch announces no loss) same controller red (base acac80bcf: expected [ Array(1) ] to deeply equal [])
T-R5L4-2 (the same late catalog keeps the selected team-2 Host selected) same same red (base acac80bcf: expected null to be 'agent-b')
T-R5M1-1 (chat drawer over Files, A held by a generic 403: the selection trail is [null], keepFocusedChat is true, toasts exactly the uncertain toast) ui/src/hooks/__tests__/useAppController.chatDrawer.test.tsx controller and drawer ChatPage red (base 7fae3f6e9: expected [ null, 'agent-b' ] to deeply equal [ null ])
T-R5M1-2 (the drawer is unmounted and mounted again: still no other Host) same same red (base 7fae3f6e9)
T-R5M1-3 (A held in the drawer, then A leaves the catalog: B is selected, toasts exactly the uncertain toast and the loss toast) same same green (contract pin); red when the listed term is dropped from the keep rule
T-R5M1-4 (after the hold the user selects B, then B leaves: C is selected) same same red (base 7fae3f6e9)
T-R3M1-1 (a carried remote row now in the local index is published once per commit; a remote-only row of the same Host stays carried) ui/src/hooks/domain/__tests__/chatListPerHostPublication.test.tsx controller, observed commits green (contract); red when the !localKeys.has(...) guard is removed, and red when the carry publishes local rows only
T-A9-T1 (team switch: no toast) same same green (contract); red under its own mutation
T-A9-P1-4 (failed refresh: the Host stays listed with its Retry row; toasts exactly Could not verify access to <name>.) same same green (contract); red when heldAgentRefs ignores the catalog of a failed query (R2-L2)
T-R1H1-T (a team switch selects a Host of the new team) same controller and ChatPage red (base f92106d69)
T-R1H1-O (same-team removal of a non-held Host: toast, then another Host is selected) same same green (contract)
T-R1L3-1 (each held Host labelled with its display name) same controller red (base 6b25f9a1f)
T-R1L3-2 (row text and Retry button name use the label) ui/src/components/__tests__/SidebarNav.test.tsx jsdom red (base 6b25f9a1f)
T-R1H1-3 ui/src/pages/__tests__/ChatPage.autoSelect.test.tsx jsdom, mocked context (now with keepFocusedChat) new context field, no base
T-R1M2-1 (a previous-team page held across the switch) ui/src/hooks/__tests__/useAppController.teamSwitchRemoteCarry.test.tsx controller, observed commits red (base f92106d69)
T-R1M3-1 (published remote rows of a Host that left the catalog) ui/src/hooks/domain/__tests__/chatListPerHostPublication.test.tsx controller, observed commits green (contract); red when the carryableAgents check is removed
T-R5A1-1a, T-R5A1-1b (team switch with team 1's catalog still cached: neither ChatPage nor the Chat nav pick selects a team-1 Host; once team 2's catalog lands, a team-2 Host is selected) ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx controller and ChatPage red (base b8ee26894)
T-R5A3-1 (no commit of the new principal lists a Retry row from the previous catalog, first render included) same controller, observed commits red (base b8ee26894)
T-R5G612-1 (×2, a foreign catalog that omits the Host or names it differently: a later loss of a team-2 held Host is still announced, with its team-2 name) same controller red (base c2584cc27)
T-A9-T1, extended (the silent team switch is followed by a team-2 loss that is announced) same same green (contract); red without the userId/teamId equality in the loss check, and red when the loss toast is removed
T-F2-2 (a verification that fails after the scope changed: no Could not verify access to toast of any Host, then a positive phase) same controller, injected 403 green (contract); red when the scope guard of verifyHostAccess is removed (M09)
T-R5F1-1 (a hold made in Agents while no drawer showed the chat: the first drawer over Files selects another Host) ui/src/hooks/__tests__/useAppController.chatDrawer.test.tsx controller and drawer ChatPage red (base f458ac18f)
T-R5F1-2 (the hold lands while the window width hides an open drawer: the drawer stays on the held Host) same same green (contract pin)
T-R5F2-1 (A held in the drawer over Files, then the Apps picker opens: no other Host is selected) same same red (base 64b56a5b6)
T-A29A1-1 (×2, verified and denied: a chat held in its full-screen tab, then Files and the drawer, which restores that chat: no other Host is selected while it is verified) same same red (base 523f328e0: expected 'agent-b' to be null)
T-A31SOL1-1 (a denied drawer request for another held Host does not end the keep of the drawer's Host) same same red (base 961901294)
T-A32SOL2-1 (the drawer's Host leaves the catalog while the drawer is closed; reopening restores held B and keeps it) same same red (base 2692891d5: expected 'agent-c' to be null)
T-A33-F1 (a hold made in Agents after a chat selection of that Host keeps the drawer on it; closing that Host's only chat tab then ends the keep and another Host is selected) same same red (base 2692891d5)
T-A34-1 (closing the last chat tab of the drawer's Host ends the keep) same same red (base 9997d58b4)
T-A36R1-1 (a successful Retry on the Host of the focused chat tab returns to that conversation) ui/src/hooks/__tests__/useAppController.heldHostRetry.test.tsx controller, ChatPage and SidebarNav red (base 043470733: active tab chatId null)
T-A36R2-1 (a hold of the Host of a chat opened from a notification keeps the drawer on it) ui/src/hooks/__tests__/useAppController.notificationDrawer.test.tsx controller and drawer ChatPage red (base 4a1a82dda)

Every negative assertion has a liveness witness in the same test, and every test has a vacuity mutation that disables the whole piece.

Mutation table, re-measured on d2e4cc57c (R5-L5). The earlier per-round tables listed only the test each mutation was written for, so they under-listed the red set. Every mutation of those tables, plus those of the later rounds, was applied alone to d2e4cc57c and run against the 22 UI test files of the held-Host and drawer work (482 tests; baseline 482 passed, EXIT=0). After each run the sources were restored and checked by sha256 (46 of 46); the tree was clean and HEAD unchanged at the end. "Red tests" is the failed count and "Red" the complete list from the JSON reporter: the test id where the title has one, else the quoted title; (×2) marks both cases of an it.each. 45 of 46 mutations turn at least one test red; the remaining one is explained below.

Mutation Red tests Red
keepFocusedChat also true for a kept Host absent from the catalog 9 T-R5M1-3, T-R5M1-4, T-A32SOL2-1, T-A9-P2, T-A23-1 (×2), T-R1H1-T, T-R1H1-O, T-R5A1-1a
keepFocusedChat always false 18 T-R5M1-1, T-R5M1-2, T-R5M1-4, T-A31SOL1-1, T-A33-F1, T-R5F1-2, T-R5F2-1, "keeps the held Host the drawer restores while its verification is verified", "keeps the held Host the drawer restores while its verification is denied", T-A32SOL2-1, T-A36R1-1, T-J1-1, T-A9-P2, T-R2L1-1, T-A23-2, T-R5L2-1, T-J1-2, T-A36R2-1
keepFocusedChat = "the kept Host is held", ignoring the catalog 3 T-R5M1-3, T-A32SOL2-1, T-A9-P2
ChatPage ignores keepFocusedChat 21 "closes the view when a plugin hands off to a preview tab", T-R5M1-1, T-R5M1-2, T-R5M1-4, T-A31SOL1-1, T-A33-F1, T-R5F1-2, T-R5F2-1, "keeps the held Host the drawer restores while its verification is verified", "keeps the held Host the drawer restores while its verification is denied", T-A32SOL2-1, T-A36R1-1, T-J1-1, T-A9-P2, T-R2L1-1, T-A23-1, T-A23-2, T-R5L2-1, T-J1-2, T-A36R2-1, T-R1H1-3
no toast for an uncertain hold of the selected Host 20 T-R5M1-1, T-R5M1-3, T-R5M1-4, T-R5F1-1, T-A33-F1, T-A34-1, T-F3-1, T-A9-P1-4, T-F3-2, T-R4L2-1, T-R4L2-2, T-R5L3-1, T-R2L2-1, T-J1-1, T-A9-P2, T-R2L1-1, T-A23-1 (×2), T-A23-2, T-R5L2-1
no revocation toast for a Host only on the stored active chat tab 3 T-R2L1-1, T-A23-2, T-R5L2-1
every revocation toasts 2 T-F3-2, T-R4M1-2
one toast per Host per mount 4 T-R2L2-1, T-R2L1-1, T-A23-2, T-R5L2-1
picker-aware ref in the revocation condition 2 T-A23-2, T-R5L2-1
no revocation toast 8 "same-scope Host cancellation reports once while another agent stays visible", T-F3-1, T-F3-2, T-R4L1-1, T-A9-O1, T-R2L1-1, T-A23-2, T-R5L2-1
verify toast without the authority-scope guard 1 T-F2-2
no loss announcement for held Hosts 14 T-R5M1-3, T-R4M1-2 (×2), T-R4L1-1, T-R4L2-1, T-R4L2-2, T-R4L2-3, T-R5L4-1, T-R5G612-1 (×2), T-A9-P2, T-A23-1 (×2), T-R5L2-1
the loss replays on return (previous catalog not advanced) 5 T-R5M1-3, T-A9-T1, T-A9-P2, T-A23-1 (×2)
iterate heldAgentRefs instead of the previous catalog 14 T-R5M1-3, T-R4M1-2 (×2), T-R4L1-1, T-R4L2-1, T-R4L2-2, T-R4L2-3, T-R5L4-1, T-R5G612-1 (×2), T-A9-P2, T-A23-1 (×2), T-R5L2-1
skip the loss for revoked holds 2 T-R4M1-2, T-R4L1-1
no loss toast 20 T-R5M1-3, T-R5M1-4, T-R4M1-2 (×2), T-R4L1-1, T-R4L2-1, T-R4L2-2, T-R4L2-3, T-R5L4-1, T-R5L4-2, T-R5G612-1 (×2), T-A9-O2, T-A9-O1, T-A9-T1, T-A9-P2, T-A23-1 (×2), T-R5L2-1, T-R1H1-O
leftCatalog without the user/team equality 1 T-A9-T1
team clause removed from the principal check 10 T-R4L2-1, T-R4L2-2, T-R5L3-1, T-R5L4-1, T-R5L4-2, T-R5G612-1 (×2), T-R5A3-1, T-R5A1-1a, T-R5A1-1b
heldAgentRefs from the raw cached catalog 3 T-R4L2-2, T-R5L3-1, T-R5A3-1
selectableAgentRefs from the raw cached catalog 2 T-R5A1-1a, T-R5A1-1b
Retry labels and hold/verify toast names from the raw cached catalog 3 "closes the view when a plugin hands off to a preview tab", T-R4L2-2, T-R5L3-1
catalog effect without the principal gate 7 T-R4L2-1, T-R4L2-2, T-R5L3-1, T-R5L4-1, T-R5L4-2, T-R5G612-1 (×2)
loss baseline advanced before the principal gate 2 T-R5G612-1 (×2)
heldAgentRefs empty while the catalog query has an error 1 T-A9-P1-4
the Retry label is the ref, in the controller 12 "keeps the held Host the drawer restores while its verification is verified", "keeps the held Host the drawer restores while its verification is denied", T-R1L3-1, T-A9-P1-4, T-R4L2-1, T-R4L2-2, T-R4L2-3, T-R5L3-1, T-R5L1-2, T-R5G612-1 (×2), T-R5A3-1
the Retry label is the ref, in SidebarNav 1 T-R1L3-2
last-known-name map cleared on every catalog (current catalog only) 4 T-R4L2-1, T-R4L2-2, T-R4L2-3, T-R5L2-1
map not cleared with the authority scope 3 T-R4L2-1, T-R4L2-2, T-R5L3-1
name resolver without the last-known-name step 4 T-R4L2-1, T-R4L2-2, T-R4L2-3, T-R5L2-1
the verify toast reads the render-captured display name first (with that name in the callback's dependencies, see note) 1 T-R4L2-3
authority scope keyed by the directory team 1 T-R5L1-1
me.teamId not trimmed 2 T-R5L1-2, "fences chat deletion to a padded me.teamId is trimmed"
keep rule without the drawer Host 11 T-R5M1-1, T-R5M1-2, T-R5M1-4, T-A31SOL1-1, T-A33-F1, T-R5F1-2, T-R5F2-1, "keeps the held Host the drawer restores while its verification is verified", "keeps the held Host the drawer restores while its verification is denied", T-A32SOL2-1, T-A36R2-1
a chat selection does not record its Host 8 T-R5M1-1, T-R5M1-2, T-R5M1-4, T-A31SOL1-1, T-A33-F1, T-R5F1-2, T-R5F2-1, T-A32SOL2-1
a drawer request for a held Host does not record it 1 T-A32SOL2-1
a drawer request replaces a recorded Host that is held and listed 1 T-A31SOL1-1
the recorded Host survives a scope change 0 none (482 green): declared, see note
closing the last chat tab of the recorded Host does not forget it 2 T-A33-F1, T-A34-1
App does not call forgetClosedChatHost 1 "activates app tabs from the strip (select and close-to-neighbor) through the seam"
a chat opened from a notification does not record its Host 1 T-A36R2-1
heldHosts without the kept chat's chatId 10 "keeps the held Host the drawer restores while its verification is verified", "keeps the held Host the drawer restores while its verification is denied", T-A36R1-1, T-A9-P1-4, T-R4L2-1, T-R4L2-2, T-R5L3-1, T-R5G612-1 (×2), T-R5A3-1
Retry does not pass chatId 1 T-A36R1-1
no !localKeys.has(...) guard on carried rows 1 T-R3M1-1
the carry publishes local rows only 3 T-H5-1, T-R1M3-1, T-R3M1-1
no carryableAgents check 1 T-R1M3-1
App passes heldHosts: [] 1 "passes the selectable Hosts, the held Hosts and the keep-focused-chat flag from the controller to NavigationContext"

Two notes on the table:

  • "The verify toast reads the render-captured display name first": applied as first written, this mutation stayed green (482 passed). verifyHostAccess is created once at mount, so without the display-name map in its dependencies the mutated read sees the empty mount-time map and changes nothing. With the map in the dependencies, which is how the defect would actually be written, T-R4L2-3 turns red; the row shows that run.
  • "The recorded Host survives a scope change" stays green, and no test is added for it. The scope reset also resets every hold, so a record left from the previous team matters only if the same ref is listed in the new team, is held again there before any chat selection, and the drawer then opens over a non-chat tab with no chat tab to restore. No user journey produces that sequence; the one-line clear stays.

44531cd74 removed the tests of the deferred loss toast and its store API (T-A9-P1-1..3, T-A9-T2, T-A9-F1/F1b, T-R1M1-1/2/S, holdKind, heldSince), because that code is gone; 2a150797e restored T-A9-P1-4 with the current toast.

The T-925 rows are existing tests, green on the base by design; their mutations remove every tombstone defense behind the tested behaviour at once. The late-ack test (T-925-M3) first stayed green under its mutation: it counted only appendMessages, while sends persist through upsertMessages since a7d2e5da8. It now counts both, and the mutation turns it red (expected 2 to be 1).

Two coverage gaps in H11, not closed here: no test drives a successful older page followed by a failed probe in the same click, and T-H11-2 does not assert that the server continuation is skipped (its fixture has no server turn markers, so that branch is unreachable before and after the change).

Desktop E2E (real app)

Each fix was also run in the real Electron app against the branch's Minikube profile, with Playwright video, trace and screenshots. The specs and evidence stay outside the repository. Each spec runs on two builds: the fix build and a base build of 57a86a470, the dev commit before these fixes. The two renderer bundles differ (ui-dist/index.html sha256); dist/main.js is the same, because the PR changes only the renderer.

Method.

  • One spec per fix. The same file runs on both builds.
  • retries: 0, and no skip or soft assertion.
  • Every "did not happen" claim is measured by an observer installed before the action. Each one has a liveness witness in the same test.
  • A held Host comes from real cluster state. The Host CR is deleted on the profile, and the send's 403 (host_missing) places the hold. The CR is restored and read back after every test.
  • Before the first base run, the first failing assertion and the observed value expected on base were written down and hashed.
Spec Claim Fix Base: first failure, observed
H16 Chat on the open chat keeps it selected: no flicker, same tab, same agent green H16-A1: the open chat's row loses aria-current 2 ms after the click
F1 Retry in Latest sessions releases a held Host green F1-A1: no Retry row
F2 a failed re-verification shows a toast and keeps the hold green F2-A1: no toast
F3a the first Send of a blank chat on a removed Host says chatllm-stateless is no longer available. once, and another Host is selected green F3-A1: no toast
F3b a Send in an open conversation on a removed Host says Could not verify access to chatllm-stateless., then chatllm-stateless is no longer available. when the Host leaves the catalog; another listed Host is selected and the removed Host is never selected again green F3b-A1: no toast
H1 Chat does not pick a held Host green H1-A1: no Host selected
H5 releasing a hold keeps other Hosts' remote rows green H5-A1: 2 rows of another Host removed
H10 the expanded session list survives 6 → 5 → 6 sessions green H10-A2: Show more shown again
H11 a failed older-page load is reported and can be retried green H11-A1: no Could not load older messages.
#925 a deleted chat stays deleted after a relaunch of the same profile green green (verification only)

Fix runs. All 10 specs passed in one run on d2e4cc57c (expected 1, unexpected 0, skipped 0, flaky 0 each, every spec EXIT=0), with the fix build rebuilt on Node 24.22.1. They passed earlier on f92106d69, 6b25f9a1f, 2a150797e, 29416e8d1, 0720afb48 and b8ee26894. No spec changed after round 5. After the run both removed Hosts were back with their saved spec and a Ready replica.

Base notes.

  • For H1 and H16, the first base run failed at the predicted assertion with a different value.
    • H1: no Host selected instead of the held Host. On base, Chat and the ChatPage auto-select both verify the held Host. The first verification releases it but its navigation epoch is stale, and the second finds nothing to release. This is the fix(stateless): contain watch loss and preserve Desktop chats #849 race listed under "Not in this PR".
    • H16: the row keeps its place in the DOM and only aria-current is cleared.
    • A replication, with the new values written down before it ran, matched in both specs.
  • The base runs predate the merges of dev ca5f0c91f and bffb8f372. The first changes mcp-host and HCC; the second changes Desktop only in the formatting of one type annotation (useAgentChatController.ts:3651) and in tests. Neither touches the code that separates the two builds.
  • F3a-A2, F3b-A2 and the toast texts changed with 44531cd74 (before: the deferred toast, and both A2 checks expected that no other Host is selected). F3b-A2 and F3b-A3 changed again with 2d9fed46b: another listed Host is selected at the end, and the alerts naming the Host are the uncertain toast then the loss toast. All of these run after A1, the predicted base assertion, so the base results stand.

Found by this work and fixed here. F3a and F3b found three gaps:

  • the uncertain-hold toast pointed at a Retry row that a removed Host does not get (now the toast makes no Retry promise, 44531cd74);
  • a catalog refresh deselected the Host with no message (now <name> is no longer available., 87212376f);
  • after the hold of the open conversation's Host, ChatPage auto-selected another Host (now it keeps the chat tab while the Host is held, 87212376f, narrowed to held Hosts in 44531cd74 and to held Hosts that still have a Retry row in 2d9fed46b).

Not covered.

  • H2 and fix(desktop-ui): invalidate the session catalog cache on Host release and rename confirmation #1130 are proven at Vitest level only.
  • F3 makes no claim about whether rpc-proxy forwarded a model turn.
  • No spec produces a revocation or switches teams, so the revoked toast and the team-switch changes of ce9e43790 and 6e947f319 are proven at Vitest level only.
  • No spec opens the Apps picker, holds a background Host or renames one, so the round-4 changes (e0e470707, 5fd232e07, 7346d8bf4, 0720afb48) are proven at Vitest level only. In every spec the Host that leaves the catalog is the focused one, and those specs passed unchanged.
  • No spec holds a Host from the chat drawer, switches teams, or has a session whose me.teamId differs from its directory team, so the round-5 changes and the later drawer, team-switch, Retry and notification changes (c96aeff2e to e8615977e) are proven at Vitest level only.
  • Two toast-absence checks, F1-A4 and H1-A2, run on the fix build only, because the base run stops at A1. Each has a witness that the toast collector was live; with the collector disabled, the witness fails.

Found outside this PR.

Validation

On 8df66b51d, with Node 24:

  • npm ci in desktop-app/ and in the repository root, npm run verify:electron and npm run build pass.
  • Full Desktop suite: 374 files, 4034 tests passed, 0 failed, 0 pending (3841 at the original base 48fcd0b30; the difference includes tests added upstream in perf(desktop): reduce chat-open latency and keep reopened transcripts in memory #1112).
  • Targeted run: 37 test files, 550 tests passed. These are the 15 test files the branch touches, 15 that use its two changed test harnesses, and 7 related ChatThread and controller suites.
  • SidebarNav.test.tsx passes 29/29 in normal order and with shuffled seeds 1–5.
  • UI typecheck, Prettier and the staged style rules pass.
  • Vacuity mutations: 63, each reverted. 62 turned their tests red. The first T-925-M3 run stayed green and led to the fix described above; its rerun is red.

On 0a5285724: the full Desktop suite passes 375 files and 4036 tests, with 0 failed and 0 pending. UI typecheck, Prettier and the staged style rules pass. Removing the new hideAllRemoteRows() call from resetChat turns T-R1H1-1 red. The rest of R1-H1 does not reproduce: the scope strip runs on the scope change itself and does not wait for loadMenuData. With loadMenuData false, the list is already empty. The catalog merge rebuilds remote rows from the new page, and T-R1H1-2 covers the deferred-load case.

On 046e2894e (merge of dev 81c60e35a), with Node 24, on the exact tree of the commit: npm run verify:electron, npm run build, tsc --noEmit (main and ui/), Prettier on the 28 files changed against dev and the style rules (0 errors) pass. Full Desktop suite: 377 files, 4076 tests passed, 0 failed, 0 pending. The six vacuity mutations still in scope (the guard in ChatPage, the four App wiring mutations, T-1130-1) each turn their tests red.

On b8b1232d5, with Node 24: npm run verify:electron, tsc --noEmit (main and ui/), Prettier and the style rules pass. Full Desktop suite: 377 files, 4076 tests passed, 0 failed, 0 pending. The new assertion sits inside an existing test, so the count does not change. Mutation: without the Host/id tie-break in byUpdatedDesc, the test fails with expected ['stateless-host','stateful-host'] to deeply equal ['stateful-host','stateless-host']; reverted.

Minikube T2 on b8b1232d5 (make minikube-t2, then make minikube-t2-runtime):

  • Run 1: T0 and T1 passed (T1: 626 tests, 82 files; Desktop suite in the lane: 377 files, 4076 tests). The user-facing health check then failed in scripts/e2e/e2e-wrc-hcc-contracts.sh with premature completion rejection changed persisted workflow status unexpectedly (T2_EXIT=2). The script posts running right after applying the recipe. The WRC reconciler's first pass creates the output anchor pod and writes initializing while the anchor is not Running (workflow-recipes/src/workflow/workflowReconciler.ts:2694-2715). When that write lands after the API's, it reverts the status. Neither file is in this PR's diff, and the same check passed on fab73b054. Tracked in test(e2e): WRC-HCC contract check races the reconciler's first pass on the output anchor pod #1145.
  • Run 2, same HEAD, make minikube-t2-runtime on the T0/T1 attestation of run 1: MINIKUBE_T2_PASS, T2_EXIT=0. The check that failed in run 1 passes.

#956 (the known switchToChat flake) was considered for this PR and left out because it did not reproduce: 25 of 25 runs green with every core saturated. Details in #956.

On 87212376f: 7 of the new A9 tests fail on the production code of b8b1232d5 and pass with the fix. T-A9-T1 and T-A9-P1-4 pass on both by design. Nine vacuity mutations were run: removing the deferred toast, always using the Retry text, removing the deselect-effect toast, dropping the same-team condition, dropping the scope and epoch guard, dropping only the epoch check, restoring the old ChatPage guard, and making refresh return nothing. Each turned its tests red. The ninth dropped only the scope check and stayed green, because a scope change also resets the hold store; the check is kept. Full Desktop suite: 377 files, 4085 tests.

On f8a9daff1 and ad8d013fe: T-A9-F1 fails on 87212376f (expected [] to deeply equal [RETRY_A]) and passes after. Reverting the guard turns it red; taking the name from the current catalog turns O1 and O2 red. Full Desktop suite: 377 files, 4087 tests.

On 3ab0d8761: T-A9-F1b fails before the change (Received "Research Host is no longer available.") and passes after. Disabling the revoked branch turns only T-A9-F1b red. Full Desktop suite: 377 files, 4088 tests.

On 6b25f9a1f, with Node 24:

  • tsc --noEmit (main and ui/), Prettier and the style rules (0 errors) pass.
  • Full Desktop suite: 379 files, 4107 tests passed, 0 failed, 0 pending.
  • Each new test of ce9e43790 and 6e947f319 fails on f92106d69:
    • T-R1H1-T: expected null to be 'agent-b';
    • T-R1M1-1 and T-R1M1-2: two toasts and one toast, where one and none are expected;
    • T-R1M2-1: expected [ { teamId: 'team-2', … } ] to deeply equal [].
  • Vacuity mutations, each reverted:
    • the per-team check answering "listed" turns T-R1H1-T red; answering "not listed" turns T-R1H1-O red (expected [ null, 'agent-b' ] to not include 'agent-b');
    • ignoring heldSince in the controller turns T-R1M1-1 and T-R1M1-2 red; breaking heldSince in the store turns T-R1M1-S, T-A9-F1 and T-A9-F1b red;
    • removing the invalidateAuthorityScope() call turns T-R1M2-1 red;
    • removing the carryableAgents check turns T-R1M3-1 red (expected [ 'local-a', 'remote-a', 'remote-b' ] to deeply equal [ 'local-a', 'remote-a' ]);
    • for R1-L8, a probe test that leaves the shared context changed fails in the next describe before the change and passes after; SidebarNav.test.tsx passes with shuffled seeds 1–12.

Minikube T2 on f92106d69: MINIKUBE_T2_PASS, T2_EXIT=0 (T1: 626 tests, 82 files).

Minikube T2 on 6b25f9a1f: MINIKUBE_T2_PASS, T2_EXIT=0 (T1: 626 tests, 82 files).

The heldSince, holdKind and deferred-toast tests above were removed in 44531cd74 together with that code.

On 44531cd74, with Node 24: tsc --noEmit (main and ui/), Prettier and the style rules pass. Full Desktop suite: 379 files, 4098 tests passed, 0 failed, 0 pending. The four mutations in the Tests section were run and reverted.

On fb1cb2a96: scripts/tests/test-minikube-t2-contract.sh and scripts/tests/test-minikube-t1-reporter-report.sh pass. Removing the private-suite exclusion from the T1 file list turns the contract test red (T1 selected the private password-recovery suite test/passwordResetPayload.externalRest.realPostgres.integration.test.ts); reverted.

On 2a150797e, with Node 24: tsc --noEmit (main and ui/) pass. Full Desktop suite: 379 files, 4099 tests passed, 0 failed, 0 pending.

Minikube T2 on 2a150797e:

On 2d9fed46b to d477e26f8: T-A9-P2 and T-R2L1-1 fail on 2a150797e and pass after; the other new tests are green on both by design and red under their mutations (see Tests). Full Desktop suite on d477e26f8: 379 files, 4103 tests.

On 29416e8d1, with Node 24.18.0:

  • npm run verify:electron, tsc --noEmit (main and ui/) pass.
  • Full Desktop suite: 379 files, 4105 tests passed, 0 failed, 0 pending.
  • T-A23-1 and T-A23-2 fail on d477e26f8 and pass after.

Minikube T2 on 29416e8d1 (make minikube-t2, planner transition targeted-sync): T0, T1 and T2 pass (T1: 684 tests, 91 files), MINIKUBE_T2_PASS, T2_EXIT=0. An earlier run on the same HEAD was stopped in T0 by a SIGTERM from outside the lane (Terminated: 15) and is void. A run on d477e26f8 was stopped by hand in T0 when the A22 review led to 11b12c476.

On e0e470707 to 0720afb48, with Node 24:

  • npm run verify:electron, tsc --noEmit (main and ui/) pass.
  • Full Desktop suite on 0720afb48: 379 files, 4112 tests passed, 0 failed, 0 pending.
  • T-A23-1 (×2) and T-R4M1-2 (×2) fail on 29416e8d1; T-A23-2 fails on e0e470707; T-R4L2-1 fails on 0e248b3c5; T-R4L2-2 and T-R4L2-3 fail on 11146a14e. Each passes after its fix. T-R4L1-1 is a pin, green before and after.
  • The mutations in the round-4 table (Tests) were run and reverted.
  • 0e248b3c5 (test only): before it, about one run in six of the held-Host tests counted the boot's post-paint catalog refresh in the refresh baseline; after it, 5 of 5 runs are green.

Minikube T2 on 0720afb48 (make minikube-t2 on a recreated profile, planner transition full-bootstrap): T0, T1 and T2 pass (T1: 684 tests, 91 files), MINIKUBE_T2_PASS, T2_EXIT=0.

On c96aeff2e to b8ee26894, with Node 24.22.1:

  • npm run verify:electron, tsc --noEmit (main and ui/) pass.
  • Full Desktop suite on b8ee26894: 379 files, 4123 tests passed, 0 failed, 0 pending.
  • Each new test is red on the parent of its fix commit and green after: T-R5L1-1 ('') on 0720afb48, T-R5L3-1 on c96aeff2e, T-R5L2-1 on db6094475, T-R5L4-1 and T-R5L4-2 on acac80bcf, T-R5M1-1, T-R5M1-2 and T-R5M1-4 on 7fae3f6e9. T-R5L1-2 and T-R5M1-3 are pins. The full suite on 27e3eaa33 found one more test that encoded the untrimmed cache scope (1 failed of 4123); d502a829a updates it.
  • Every mutation in the tables (Tests) was re-run on b8ee26894 (R5-L5).

Minikube T2 on b8ee26894 (make minikube-t2 on a recreated profile, planner transition full-bootstrap): T0, T1 and T2 pass (T1: 684 tests, 91 files; Desktop suite in the lane: 379 files, 4123 tests), MINIKUBE_T2_PASS, T2_EXIT=0.

On b8ee26894 to d2e4cc57c (the merge of dev included), with Node 24.22.1:

  • npm run verify:electron and tsc --noEmit (main and ui/) pass on d2e4cc57c.
  • Full Desktop suite on e8615977e: 388 files, 4523 tests passed, 0 failed, 0 pending.
  • Each new test is red on the base listed in its Tests row and green after its fix commit.
  • Every mutation in the mutation table (Tests) was re-run on d2e4cc57c: 45 of 46 turn at least one test red; the other is explained under the table.

Minikube T2 on d2e4cc57c (make minikube-t2): T0, T1 and T2 pass (T1: 684 tests, 91 files; Desktop suite in the lane: 388 files, 4523 tests), MINIKUBE_T2_PASS, T2_EXIT=0.

Review rounds:

  • 62f62def4..9edf68d26 was reviewed by GPT-6.1 Sol, GPT-6 Sol, GLM-5.3, Muse Wonder Full and Grok 4.7 Fast. The two findings that needed a change (Retry left the mobile menu open; identical Retry names) are fixed in 2033c8616.
  • 62f62def4..2033c8616 was signed off by Claude Opus 5.5 and Claude Fable 5.1 and audited by GPT-6 Astra. The audit found a test that broke later tests under a shuffled order and a missing focus destination when the narrow-window menu closes. These, and the reviewers' notes on focus after Retry, the session options menu and the visible word "Retry", are fixed in ff7899b18.
  • 2033c8616..ff7899b18 was reviewed by the same three models. Two findings needed a change: the narrow-window backdrop closed the menu without moving focus, and one focus test had no liveness witness of its own. Both are fixed in 8df66b51d.
  • ff7899b18..8df66b51d was signed off by Claude Opus 5.5 and audited by GPT-6 Astra (pass). Both confirm the two fixes above. One note was left as is: see the comment item under "Not in this PR".
  • Two independent reviews of 87212376f found that a second denial while the loss toast was pending dropped it. Fixed in f8a9daff1; ad8d013fe adds the revocation variant found by the second review.
  • External review r3 found that a revocation arriving while the loss toast is pending was announced as a loss. Fixed in 3ab0d8761.
  • Review 6271309203 (f92106d69):
  • f92106d69..6b25f9a1f and the realism review of the held-Host handling (three independent reviewers) led to 44531cd74, which removes the deferred toast, heldSince/holdKind, the per-team listed set and the <name> (<ref>) label.
  • Review 6278200614 (44531cd74), validated by three independent reviewers:
    • Fixed in round 3: R2-M1 (2d9fed46b) and R2-L1 (142880262); see F3, "Keeping the chat tab" and its decision table.
    • R2-M3, closed in writing: display names are not guaranteed distinct. The Retry row uses the same name as the toasts and the agent switcher (AgentWorkspace.tsx:286), which do not tell same-name Hosts apart either; a ref suffix only in the Retry row was the <name> (<ref>) branch removed in 44531cd74.
    • Fixed (tests and comments only): R2-M2 (2d67ff2a7), R2-L5 (555ed4804), R2-L2 in two parts (2a150797e, 44013d259).
    • R2-L3 (mcp-host fixture split): stays in this PR; see round 4 below.
    • R2-L4 (commit hygiene): no history rewrite. The new commits carry the finding id in the subject and Class-check: / Repro-test: trailers.
  • Review 6280900525 (2a150797e):
    • R2-M1, R2-L1 and R2-L2: fixed, as above.
    • R3-M1: 9c326b615, tightened in 29416e8d1.
    • R3-L1 and R3-L2: d477e26f8.
    • R3-L3: the line on fb1cb2a96 at the top of this body.
    • R1-M4: this body. R2-M3, R2-L3 and R2-L4: as above.
  • The round-3 diff (2a150797e..d477e26f8) was reviewed by GPT-6 Astra (no blocking findings). ASTRA-1 (a held Host behind an open Apps picker was announced as if its tab were focused) and ASTRA-4 (a stale comment) are fixed in 11b12c476, ASTRA-2 (T-R3M1-1 did not require the carry itself) in 29416e8d1. ASTRA-3 (a positive toast control inside T-F3-2) was not adopted: T-F3-1 and T-R2L1-1 already fail when the toast branch is removed.
  • Review 6294449740 (29416e8d1), validated by three independent reviewers:
    • R4-M1: fixed in e0e470707 (every held Host that leaves the catalog is announced, whatever has focus) and 5fd232e07 (a revocation behind the Apps picker is announced). T-A23-1 is rewritten: it pinned the silence and now requires the toast.
    • R4-L1: both toasts are kept and declared (F3), pinned by T-R4L1-1 (762bc9607).
    • R4-L4: listed under "Not in this PR".
    • R4-L2: fixed in 7346d8bf4, tightened in 0720afb48.
    • R4-L3: the mutation table is corrected (Tests).
    • R4-L5: the invalidateAuthorityScope comment names every continuation it cuts (11146a14e).
    • R4-L6: the T-F3-1 title is fixed in 11146a14e. The reflow in d477e26f8 (useAgentChatController.ts:3677-3679) is Prettier formatting only. Round-4 commits carry Class-check: lines with the pattern, the site count and the decision.
    • R2-L3: 154442d00 stays in this PR. dev (bffb8f372) still has fs.cpSync(… errorOnExist: true …) at devGfsStoreFixture.ts:105, and ci-public.yml runs the mcp-host matrix with Node 24 on every PR, so without the commit this PR's CI is red until another PR lands the same fix. The change is test-only and reaches dev with this PR.
    • R2-L4: closed in writing; see round 2.
    • R3-L3: dev will be merged here once feat(mcp-host): durable model-step checkpoint and Retry model step (#1043) #1047 lands, and fb1cb2a96 then drops out of the diff.
  • The round-4 diff (29416e8d1..11146a14e) was reviewed by GPT-6 Astra. R4-ASTRA-1 (a team switch could copy the old team's names into the last-known-name map before the new catalog arrived) and R4-ASTRA-3 (a verification pending across a rename used the old name) are fixed in 0720afb48. R4-ASTRA-2 (four or more simultaneous losses exceed the 3-toast stack) is listed under "Not in this PR".
  • Review 6294449740, round 5 (0720afb48), all six findings reproduced on 0720afb48 before any change:
    • R5-L1: c96aeff2e (the authority scope uses the trimmed session team), witness follow-up in d502a829a, test tightened in b8ee26894.
    • R5-L3: db6094475 (held Hosts are named only from the current principal's catalog).
    • R5-L2: acac80bcf (every hold toast falls back to the last known name).
    • R5-L4: 7fae3f6e9 (the catalog effect ignores another principal's catalog).
    • R5-M1: eefce7b6e (the drawer stays on the interrupted Host while it is held).
    • R5-M2: 27e3eaa33 (producer-shaped catalogs in the Host-name tests).
    • R5-L6: 6f1313342 (the comment gives the reason, not a plan reference).
    • R5-L5: every mutation row re-measured on d2e4cc57c, with the complete red list (Tests).
    • The Class-check: of 7346d8bf4 ("the others only run for Hosts in the current catalog") did not hold: R5-L3 showed the hold toast and the Retry labels running with a stale catalog during a team switch. Corrected in db6094475, whose Class-check: counts every agentDisplayName( call site: 2 in useAppController moved to the resolver, 5 elsewhere kept (chat notifications and chat list titles, not hold paths).
    • R4-L6: 11146a14e and 0720afb48 stay as pushed; splitting them needs a force-push. Each round-5 commit closes one finding.
    • R2-L3, R3-L3, R2-L4: as in round 4. dev (bffb8f372) still has the cpSync line, and feat(mcp-host): durable model-step checkpoint and Retry model step (#1043) #1047 is still open.
  • The round-5 diff (0720afb48..d502a829a) was reviewed by GPT-6 Astra. R5-ASTRA-2 (T-R5L1-1 placed the hold after the directory arrived, so its mutation stayed green) is fixed in b8ee26894. R5-ASTRA-1 and R5-ASTRA-3 are fixed in c2584cc27 (below).
  • Regression review of b8ee26894 by GPT-6 Sol, GPT-6.1 Sol, GLM-5.3, Grok 4.7 and DeepSeek V4.1 Flash. GPT-6 Sol found two regressions in round 5's own drawer change, both reproduced before any change. R5-ASTRA-1, R5-ASTRA-3 and two of the findings share one cause: the selection and hold readers took the cached catalog without checking its principal.
    • c2584cc27: holds, ChatPage auto-select and the nav-chat pick read only the current principal's catalog (R5-ASTRA-1, R5-ASTRA-3).
    • f458ac18f: a catalog of another principal never becomes the loss baseline (R5-REG-G612).
    • 64b56a5b6: the drawer's Host is recorded only when the drawer is open, including width-suppressed (R5-REG-F1).
    • 9498bc661: the drawer keeps its Host under the Apps picker (R5-REG-F2).
    • The same five models reviewed this design before it was implemented. Their findings changed it in three places: the drawer's open intent instead of its visibility, no baseline clear on a scope change, and two in-window assertions that now expect no Retry row.
  • 523f328e0 merges dev (ce1b21abf). The dev test App.activationSeam.test.tsx needed the new controller field in its mock (fixture only).
  • The b8ee26894..9498bc661 diff was reviewed by GPT-6 Astra. A29-ASTRA-1 (a conversation held full-screen and restored by the drawer lost its keep) is fixed in 6017ac9a1. A29-ASTRA-2 (four no-toast tests had no positive toast phase) is fixed in d57b3fb90; 961901294 types the new test helpers for the renderer tsc.
  • GPT-6 Sol reviewed each following diff:
    • A31-SOL-1: a denied request for a second held Host replaced the drawer's Host. Fixed in 2692891d5.
    • A32-SOL-2: the drawer kept a Host that had left the catalog after it closed. Rather than add a fifth write to the inferred marker, 9997d58b4 takes the drawer's Host from the last chat selection or drawer request, and removes the marker, its writes in the hold path and the App-owned drawer ref.
    • A33-SOL-P2: closing the last chat tab of that Host did not end the keep. Fixed in f550f0fe8; 043470733 corrects the comments (A33-SOL-P3).
    • A34 r2: the close projection can miss a tab of the same Host still uncommitted from an async notification open. Listed under "Not in this PR".
  • A blind review of 043470733 by six reviewers who had only the repository, the diff and this PR's stated goal:
    • R1: a successful Retry opened a blank chat instead of the conversation on screen. Fixed in 4a1a82dda: the Retry row carries the kept chat's chatId.
    • R2: a chat opened from a notification did not record its Host, so a later hold moved the drawer to another Host. Fixed in 8348f9df4.
    • R3: T-F2-2 matched one display name and stayed green with the scope guard removed. Fixed in 22b1d559d.
    • Three comments corrected in e8615977e.
    • The other findings need a timing window or a sequence that no user journey produces. They are listed under "Not in this PR".
  • d2e4cc57c merges dev (899a76016).

Not in this PR

  • Two concurrent verifications of the same held Host select nothing (pre-existing fix(stateless): contain watch loss and preserve Desktop chats #849 defect). A double-click on Retry is the easiest way to trigger it (R1-L1): Desktop: two access verifications of the same held Host select nothing (double-click on Retry) #1150.
  • A selected Host that drops out of the catalog during a temporary team switch is announced as no longer available, then comes back (R1-L5). The drop itself is Backend-1.
  • A transient same-scope catalog omission also removes a held Host's Retry row, announces the loss, and may select another listed, non-held Host. Its return restores Retry eligibility, not the previous selection (R4-L4). The renderer cannot tell a transient omission from a removal; the omission is Backend-1.
  • Four or more Hosts lost at once exceed the 3-toast stack (useToastController.ts:17), so the oldest toasts are dropped (R4-ASTRA-2). The cap predates this PR, and no multi-Host removal has been observed.
  • During a team switch, until the new team's catalog lands, the agent switcher, FleetBoard, the header and the Agents page still list the previous team's Hosts. A click selects one and the server answers 403. Clicking Chat in that window resumes the open conversation of the previous team; the server answers 403 and the Host is held. Automatic choices (ChatPage auto-select, the nav-chat pick) and holds read only the current principal's catalog since c2584cc27. Pre-existing.
  • A Host held in the new team before that team's catalog first listed it gets no loss toast when it is absent: it was never listed, so nothing was lost.
  • A team switch can reuse a catalog read of the previous team that is still in flight (A-3), and a catalog of another team leaves nothing selectable until the current team's catalog lands (B-3). Both are pre-existing.
  • A pending loadSession denial can hold a Host again after the user has selected another one. Present on dev; no user journey shown.
  • switchTeamForWorkspace writes an untrimmed team id to currentTeamIdRef for one render; the next render writes the trimmed value.
  • A local-store loadMessages error whose text parses as 401 or 403 would hold the Host. The store does not produce such an error.
  • A repeated hold of the same kind does not refresh the catalog (A-4).
  • After an uncertain hold of the drawer's Host, a later revocation of that Host gives no toast: the drawer shows the blank landing, which names no Host (A-2).
  • The Retry row of a revoked Host says "Couldn't verify access" and offers Retry, the same text as an uncertain hold (D-2).
  • New chat in the drawer does nothing while the drawer keeps a held Host (B-2).
  • If the user closes the only tab of the drawer's Host while still reading it in the drawer, and that Host is held later, the drawer moves to another Host (B-1). In the same way, a tab of that Host activated by an async notification open and not yet committed when the user closes its other tab is not counted, so the keep ends while a tab still exists (A34 r2). This needs the close click within one scheduler task of the notification open.
  • A missing or corrupt older local page now shows Could not load older messages. with no server fallback; base returned no rows. This is the H11 contract (C-1). The same message appears when server turns rendered but the local cache write failed, for example on a full disk (C-2).
  • The cross-device tombstone gap of Deleted chats can reappear: server-only sessions are listed again and in-flight sends recreate the chat #925 stays open.
  • The other reload source (catalog refresh during a temporary team switch) is Backend-1.
  • The sidebar does not track window resizes. If the overlay menu was open and the window is then widened, the next sidebar navigation moves focus to the logo button, which is visible.
  • In a wide window, choosing an item from the Settings popover closes the popover with the focused item, and focus falls to the page body. This is unchanged from before this branch.
  • The comment above closeSidebarMenus (SidebarNav/index.tsx:224-226) says every sidebar navigation closes the menus. New chat and the file tree do not call it; that behaviour predates this branch, and only the comment wording is overstated.
  • If a hold and its release both land before the release observer renders, that Host's remote rows stay out until the next reload. Verification crosses an IPC await, so Retry does not hit this.

🤖 Generated with Claude Code

alfredolopez80 and others added 8 commits October 16, 2026 00:56
hostAuthorityStore.hold returned true for every accepted hold, so a
repeated failure on an already held Host re-ran blockHostAccess: it
bumped the authority revision, cleared the selection again and refreshed
the agent catalog on every repeated 401/403.

hold now returns true only when the hold state changes (unheld to held,
or uncertain to revoked). A repeated hold of the same kind still advances
the Host epoch, so a verification that started before the new failure
can no longer release the Host. revoked to uncertain is still refused
without moving the epoch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a Host was held (a catalog or transcript read rejected with 401/403),
blockHostAccess cleared the selection and the two auto-selection paths
picked the most recent Host again, which was often the held one. The
sidebar then flickered between the held Host and an empty selection.

useAppController now exposes heldAgentRefs, recomputed on every authority
revision, through NavigationContext. ChatPage's auto-select and the
handleNavSelect branch with no focused chat both pick the most recent Host
among the ones that are not held. With every Host held, nothing is
selected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking Chat while already on the Chat route with a chat open opened a
"New chat" tab of the most recent agent. nav.handleNavSelect clears the
selection and returns the focused chat; handleNavSelect then reopens it
through selectChatAgent, whose fast path (agent already selected, Chat
route) loaded the chat with switchToChat but never selected the agent
again. The selection stayed empty, and ChatPage's auto-select picked the
most recent agent.

The fast path now calls nav.setSelectedAgent(agentName). It runs in the
same batch as the nav's setSelectedAgent(null), so no commit has an empty
selection and the agent-selection effect does not re-run. For the other
fast-path callers the agent is already selected and the call is a no-op.

The defect came from fc3217d routing the focused chat through
handleSelectChatAgent, on top of the older fast path from 7828e0f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skipping held Hosts in the chat auto-select removes the loop that used to
re-verify them by accident. Nothing checks a held Host again until the user
picks it, so three cases now need to be visible:

- A held Host's sessions are hidden from the sidebar with no reason given.
  The "Latest sessions" block now renders one row per held Host,
  "Couldn't verify access to <agentRef>", with a Retry button that selects
  the Host the same way the agent picker does. The row also renders while
  the session list is empty or loading.
- A Retry whose access verification fails did nothing: verifyHostAccess
  swallowed the error. Its catch now logs the agent ref and the HTTP status
  (never the error message, which carries an unsanitized excerpt of the
  response body) and, if the authority scope did not change, shows a toast.
  It still returns false.
- Losing access to the selected Host cleared the selection, and the chat
  auto-select then moved to another Host without saying why.
  blockHostAccess now shows a toast when it clears the selection, with
  separate texts for a confirmed revocation and an uncertain hold. A hold
  on a Host that is not selected shows no toast; the sidebar row covers it.

The delete-storage feedback test that revokes the selected Host before a
cancelled delete now expects the revocation toast ahead of the single
"Delete cancelled" toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every reload of the cross-agent "Latest sessions" list published the local
rows first and then each Host's catalog. That first publication replaced the
whole list, so remote rows already on screen disappeared until their Host's
catalog came back, and the sidebar flickered on every reload.

The local publication now keeps the remote rows already published for Hosts
the reload is about to read, unless the reload's local index tombstoned them
or a local row has the same key. Each Host's catalog page then rebuilds its
remote rows from the page itself, so a row the server no longer lists goes
away in the same commit as the merge.

Carried rows must not outlive the authority that produced them:

- An uncertain hold drops the Host's remote rows at once
  (hideRemoteRowsOf, called from hideHostAccess); a revocation already
  hides the whole Host. After a release, the Host's remote rows come back
  with its next catalog, not from the rows published before the hold.
- A change of authority scope drops every remote row before the first
  publication of the new scope, where a Host can have the same name.

Tests: the reload, tombstone, hold-and-release and scope-change cases in
chatListPerHostPublication.test.tsx, and write witnesses for the existing
#925 deletion tests. The late-ack test for a deleted chat counted only
appendMessages, while sends persist through upsertMessages since a7d2e5d;
it now also checks that the deleted chat receives no upsertMessages write.

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

After "Show more", the agent sessions card collapsed back to the preview
whenever the list dropped to five rows or fewer with no remote page left,
for example while a reload published only local rows. When the list grew
again the user had to expand it a second time.

The card now stays expanded until another agent is selected. The reset
happens during render when the selected agent changes to a different
non-null agent, because ChatThread is not keyed by agent. A null selection
does not count as a change: a held Host clears the selection to null, so
A -> null -> A keeps the expansion and A -> null -> B resets it. The cleanup
of the session menu and the rename field when their session leaves the list
is unchanged.

Closes #1109

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two reads of older local messages swallowed their errors. The probe that
decides whether "Load older" is shown turned a failed read into "no more
history" and hid the button. The read behind the button turned a failed
page into an empty one, so the click did nothing and nobody saw why.

The probe now logs the failure and keeps "Load older" visible. The page
read no longer catches its error: it reaches the existing outer handler,
which still hides a Host on an authorization error and already logs
"failed to load older messages". For other errors that handler now shows
"Could not load older messages." when the request is still for the
visible chat; after a chat switch it only logs.

A stateful chat whose old local page cannot be read no longer continues
silently to the server pages; the click reports the failure instead.

Closes #1110

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

The renderer shares one listSessions request per Host and query for five
seconds. Two events change what a cached page means, and neither dropped it.

Releasing a hold. A catalog read can enter the cache while its Host is held:
the cross-agent loader's read can start before the hold and fail with a
generic 403 after it, and readSessionCatalog then retries with force. The
first reader after verifyHostAccess released the Host shared that request.
When it was rejected as revoked, the Host was held again right after it
had been verified. verifyHostAccess now invalidates the Host's cached
requests after a successful release, as blockHostAccess already does when
it holds.

Confirming a rename. Once the rename is confirmed its pending marker is
removed and the server title wins the merge. A reload inside the cache
window merged a page read before the rename, so the old title came back
until the entry expired. attemptRenameRpc now invalidates the Host's
cached requests where it removes the marker.

The cache TTL is unchanged.

Closes #1130

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alfredolopez80 and others added 4 commits October 16, 2026 01:20
Two comments from this branch no longer said what the code does.

The cross-agent catalog updater described carried remote rows by
reference to the code before the carry existed. It now states the rule:
remote rows are rebuilt from the page, and carried rows missing from it
are dropped.

The Host hold catalog cache test file described only the hold-side
invalidation, while it also covers the release-side one.

No behaviour change.

Refs: #1130
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…button

The Retry row added for a held Host had two gaps that review found.

On a narrow window, Retry selected the Host but left the navigation menu
open, so the recovered chat stayed under the overlay. Every other
selection control in the sidebar closes the menu; Retry now does the
same. It closes on click, as the session rows do: the selection handler
does not return the verification result, and a failed verification
still reports itself with a toast.

With several held Hosts, every button's accessible name was "Retry".
Each button is now named "Retry access to <host>", following the
"Open <title>" names of the session rows.

T-F1-1 looks the button up by its new name. New cases cover two held
Hosts (distinct names, each retrying its own Host) and the mobile menu
closing after Retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The final review of the held-host Retry row found three behaviours that
were left over from the separate close calls in the sidebar.

On a narrow window, closing the navigation menu hid the focused control
and left keyboard focus nowhere visible. The five controls that navigate
from the sidebar (nav items, session rows, app rows, Retry and logout)
now share one close step. It closes the navigation, Settings and session
options menus, and when the navigation menu was open it moves focus to
the logo button that reopens it, as the Settings menu already does for
its trigger. Retry therefore also closes an open session options menu.

In a wide window, a successful Retry removes its row together with the
focused button, so focus fell to the page body. When the Host the user
retried leaves the held rows and focus is on the body, focus now moves
to the Chat item. Focus that the user has moved elsewhere is left alone.

Test changes: the narrow-window test stubs now restore the previous
matchMedia stub. The old spy restore reset the shared stub and broke
later tests under a shuffled order, and an older stub never restored
its narrow viewport. The visible word "Retry" is now asserted, because
the access toast tells the user to use it. New cases cover the focus
destinations and the session menu.

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

On a narrow window, the backdrop button that closes the navigation menu
hid itself with keyboard focus on it, so focus fell to the page body.
The backdrop now uses the same close step as the sidebar's navigation
controls, which moves focus to the logo button that reopens the menu.
The step is renamed closeSidebarMenus because the backdrop does not
navigate.

The test that leaves user-moved focus alone after a successful Retry
now first shows, in the same test, that the focus effect moves body
focus to the Chat item. Without that step the test also passed with
the effect removed.

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

Copy link
Copy Markdown
Contributor Author

@claude Please do a full review of this PR and an audit of the findings it closes, and end with a clear verdict: approve or do not approve, with the reasons.

Stacking. This PR is stacked on #1112. Its base branch is fix/issue-1091-chat-open-latency-v2 at 62f62def4, the head of #1112. It merges after #1112. Review only 62f62def4..HEAD, and judge it as landing on top of #1112. Code that #1112 adds is out of scope, except where this PR depends on it.

Full review. Check correctness, regressions, test quality and the repository rules (CLAUDE.md, AGENTS.md, docs/agents/frontend-style-rules.md, docs/agents/desktop-ui-rules.md, desktop-app/ui/docs/STYLE_STANDARDIZATION.md). For tests, check two things:

  • every negative assertion has a liveness witness in the same test;
  • each new test fails on its base for the defect it pins.

Closure audit. The PR description lists what each commit fixes. For each item below, say whether the code at HEAD closes it, with path:line.

  • Flicker (H5) and the H1, H2, H10, H11 and H16 fixes, and fix(desktop-ui): invalidate the session catalog cache on Host release and rename confirmation #1130. See the Summary table.
  • Review findings on F1, fixed in 2033c8616.
    • Retry left the mobile menu open.
    • Every Retry button had the same accessible name.
  • Sign-off findings, fixed in ff7899b18.
    • A matchMedia stub that broke later tests under a shuffled order, and an older stub that leaked the narrow viewport.
    • No focus destination after the narrow-window menu closed.
    • Retry left the session options menu open.
    • Focus fell to the page body after a successful Retry.
    • No test pinned the visible word "Retry".
  • Commit-11 review findings, fixed in 8df66b51d.
    • The backdrop closed the narrow-window menu with no focus destination.
    • T-D15-4 had no liveness witness in the same test.
  • Items listed under "Not in this PR". Confirm they are out of scope and not regressions.

The production changes are in the Desktop renderer only (desktop-app/ui/). Outside it, the branch changes one main-process test (desktop-app/src/__tests__/chatStore.test.ts, a witness added). Do not push commits to this branch. Answer in a single review comment.

@claude

claude Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Claude finished @alfredolopez80's task in 3m 55s —— View job


PR review — stop latest-session rows from flickering, and related sidebar fixes

Reviewed 62f62def4..8df66b5 (12 commits), judged as landing on top of #1112. Scope held to the Desktop renderer (desktop-app/ui/) plus the one main-process test witness. I read every production hunk and the new/changed tests; I did not re-run the Vitest suite (no node_modules on this checkout and a Node‑24 npm ci is out of scope for a review), so the base-failure / green claims below are judged from the code, not re-executed.

  • Gather the diff and read the production changes
  • Correctness & regression review
  • Repository-rule compliance
  • Test quality (liveness witnesses, base-failure design)
  • Closure audit
  • "Not in this PR" items
  • Verdict

Correctness

The production surface is small and the reasoning holds up:

  • H2 — hostAuthorityStore.ts:54. hold now returns current?.kind !== kind, so a same‑kind repeat still bumps the epoch (invalidating an in‑flight verify) but returns false; blockHostAccess early‑returns on !hold(...) (useAppController.ts:359), skipping the catalog refresh and selection clear. The revoked→uncertain guard is preserved. A same‑kind repeat never re‑clears the selection — safe here because a failed Retry never leaves the selection pointing at a held Host (asserted by T‑F2‑1).
  • H1 — ChatPage.tsx:30 and useAppController.ts:1225. Both auto‑selections filter held Hosts out of pickLatestAgent. heldAgentRefs/isHostAccessBlocked are recomputed off hostAuthorityRevision, so the filter tracks holds.
  • H16 — useAppController.ts:1116. The fast path (nav.selectedAgent === agentName) now calls setSelectedAgent(agentName) in the same batch as handleNavSelect's setSelectedAgent(null), so no commit shows an empty selection and ChatPage never auto‑selects a different agent. A genuine no‑op on the other fast‑path callers (the guard requires the agent already selected).
  • H5 — useChatListController.ts:1668‑1691, :1743. The initial publication carries already‑published remote rows whose Host is still carryable and not deleted/superseded; each Host's catalog page then rebuilds its remote rows (cached now excludes remote). Scope change drops remote rows first (:394‑396) so a same‑named Host in the next scope can't inherit stale rows. hideRemoteRowsOf (:502) drops only remote rows on an uncertain hold; hideAgent still drops everything on a revocation. Coherent, and the carry is bounded to requestedAgentNames.
  • H10 — ChatThread.tsx:315‑321. The React-supported "adjust state during render" pattern keyed on selectedAgent; a hold clearing the selection to null is guarded out, so the card stays expanded until the agent actually changes. The old auto‑collapse effect is removed.
  • H11 — useAgentChatController.ts:238, :1504, :1604. The probe logs and returns true on any rejection (keeps "Load older" visible); the local page read no longer swallows to []; the outer catch toasts Could not load older messages. when still relevant. The accepted trade‑off (a corrupt local page now surfaces instead of silently falling through to the server) is documented and reasonable.
  • fix(desktop-ui): invalidate the session catalog cache on Host release and rename confirmation #1130 — useAppController.ts (verifyHostAccess invalidateSessionCatalog after a successful release) and useChatListController.ts:1215 (invalidate on rename‑marker removal). Both close the stale‑cache windows described.
  • F1/F2/F3 — SidebarNav/index.tsx:445‑462, useAppController.ts:361‑369 / :397‑405. Held rows render with a per‑Host Retry access to <agentRef> name; F2 logs status only (httpErrorStatus(error) ?? null), not the message body — compliant with the logging standard (no response excerpt leaked). F3 distinguishes revoked vs. uncertain toasts and stays silent for an unselected Host.
  • Focus — SidebarNav/index.tsx closeSidebarMenus + the heldAgentRefs effect. The five navigating controls and the backdrop share one close step; it focuses the logo button only when the overlay was open, and a wide‑window verified Retry moves body focus to the Chat item without stealing focus the user has since moved.

No regressions found in the changed code. Styling is token‑based (--space-1, --text-dim, --font-size-2xs, --line-height-tight), the hover‑disable uses background: transparent (no transform/filter, so da-no-hover-motion is satisfied), routes come from DESKTOP_ROUTES, and no new component CSS file is added — consistent with STYLE_STANDARDIZATION.md and the frontend rules.

Test quality

Spot‑checked the new/changed suites. Every negative assertion I inspected has a same‑test liveness witness:

  • useAppController.heldHosts.test.tsx: T‑F2‑2 witnesses the rejection reached the verify catch (the console.warn) before asserting no toast; T‑F3‑2 witnesses B is held/exposed and A still selected before asserting no toast; T‑H1‑4 asserts auto‑select ran and picked a Host before asserting the held Host got 0 verify reads.
  • SidebarNav.test.tsx: T‑D15‑2 opens the options menu (witness) before asserting Retry closes it; T‑D15‑4 witnesses the focus effect moving body→Chat in its first phase before asserting user‑moved focus is kept; T‑D15‑3 walks held→released transitions. The matchMedia fix is real — stubNarrowViewport swaps and restores the previous stub via onTestFinished (lines 73‑94), so the narrow viewport no longer leaks across order.
  • chatListPerHostPublication.test.tsx: T‑H5‑1 witnesses the local row published and waiting (every pre‑merge commit still contains remote-r), then the merge commit, then asserts the old row is dropped only after the new catalog merges.

The base‑failure design (each test red on its stated base; T‑925 rows green‑by‑design witnesses; the T‑925‑M3 upsertMessages correction) is internally consistent and matches the code it pins. I did not independently re‑execute the 63 vacuity mutations.

Closure audit

Item Closed at HEAD? Anchor
Flicker (H5) Yes useChatListController.ts:1668‑1691, :1743, :394
H1 auto‑select skips holds Yes ChatPage.tsx:30, useAppController.ts:1225
H2 idempotent same‑kind hold Yes hostAuthorityStore.ts:54 + useAppController.ts:359
H10 card stays expanded Yes ChatThread.tsx:315‑321
H11 surfaces failed older reads Yes useAgentChatController.ts:238, :1504, :1604
H16 Chat opens focused agent Yes useAppController.ts:1116
#1130 cache invalidation Yes verifyHostAccess release + useChatListController.ts:1215
F1 Retry left mobile menu open Yes SidebarNav/index.tsx Retry onClick → closeSidebarMenus (T‑D13‑2)
F1 identical Retry names Yes aria-label={Retry access to ${agentRef}} (T‑D13‑1)
matchMedia stub leak Yes SidebarNav.test.tsx:73‑94
No focus after narrow‑menu close Yes closeSidebarMenus logo focus (T‑D15‑1)
Retry left session options menu open Yes shared close step (T‑D15‑2)
Focus fell to body after Retry Yes heldAgentRefs effect → Chat item (T‑D15‑3)
No test pinned visible "Retry" Yes .textContent).toBe('Retry') (T‑D13‑1)
Backdrop closed menu w/o focus Yes backdrop onClick={closeSidebarMenus} (T‑D17‑1)
T‑D15‑4 no in‑test witness Yes witness at SidebarNav.test.tsx:590

"Not in this PR" — I confirm each is genuinely out of scope and not a regression introduced here: the two concurrent‑verify #849 defect, the #925 cross‑device tombstone gap, the team‑switch reload (Backend‑1), the in‑flight rename read publishing the old title once, resize not being tracked, the Settings‑popover focus fall, the overstated closeSidebarMenus comment, and the hold+release‑before‑render race (verification crosses an IPC await, so Retry doesn't hit it). None are touched or worsened by these commits.

Minor, non-blocking notes

  • The held row prints the raw agentRef ("Couldn't verify access to {agentRef}") while every toast uses agentDisplayName. This matches the F1 spec as written, but a user who only knows the display name sees two different identifiers for the same Host. Consider the display name in the row too, as a follow‑up.
  • heldAgentRefs' useMemo lists hostAuthorityRevision as a dep it doesn't read in the body (it's the recompute trigger). Intentional and correct, just worth a one‑word comment so a future reader doesn't "clean it up".

Neither blocks merge.

Verdict

Approve. (Advisory — I can't submit a formal GitHub approval.) The production changes are correct, scoped to the renderer, rule‑compliant, and each closed finding has a matching code anchor and a test with an in‑test liveness witness. Remaining gaps are explicitly carried as out‑of‑scope and are not regressions. My one caveat is that I judged the red‑on‑base and vacuity‑mutation claims from reading rather than re‑execution; the diff and the PR's documented Node‑24 run (374 files / 4034 passing) are consistent with that reading.
· fix/desktop-sidebar-flicker

@alfredolopez80

Copy link
Copy Markdown
Contributor Author

Evenfire Reviewer — Round 1

Reviewed head: 8df66b51df702559f53d89f89f2bb05703f35244

Decision: Blocked

Reviewed as the delta on top of #1112 (62f62def4), not as a diff against dev. The hold, auto-select, fast-path, Retry and catalog-cache changes hold up. The new remote-row carry does not survive a team switch: resetChat() never clears latestChatSessions, and the effect that strips the previous scope's remote rows only runs once loadMenuData flips true after the post-paint delay.

Must fix

  • R1-H1 — High — desktop-app/ui/src/hooks/domain/useChatListController.ts:1681 — Clear the latest-session list (or its remote rows) synchronously in the confirmed team-switch teardown. Today the carry republishes the previous team's remote rows, keyed only by agent name, until postPaintDataReady becomes true, and the next catalog merge can adopt any whose chat id also exists in the new team. T-H5-5 does not cover this: it changes the team prop while loadMenuData stays true.

Verified

  • 62f62def4 is an ancestor of this head and is still the head of perf(desktop): reduce chat-open latency and keep reopened transcripts in memory #1112. Diff is 27 files, +1689/−100, all desktop-app, matching GitHub.
  • Same-kind hold still advances the epoch but no longer re-runs the catalog refresh; both auto-selects skip held Hosts; the Chat fast path restores the focused agent in the same batch.
  • Retry rows, verification toasts, status-only logging, catalog invalidation on release and on rename confirmation, and the narrow-window focus handoff all match their tests. CI on this head is 41/41 green, including desktop-app.

Validation gaps

  • No test drives switchTeamForWorkspace and asserts the previous scope's remote row is gone while loadMenuData is still false.
  • Previous-head regression proof: not verified in this environment.

PR description

Update required. The body is accurate on the flicker mechanism, but "Not in this PR" assigns the team switch to Backend-1. The case where the Host stays in agentNames across the switch is in this PR, and the carry handles it wrong.

Pre-existing

Pre-existing: none identified during the scoped review.

Next action

Clear carried remote rows synchronously with the team switch and add the regression described in R1-H1.

Full review archive: pr-reviews/pr-1131/r1-26-10-16.md

— Review performed by Evenfire Reviewer

…down

A confirmed team switch changes the chat scope in the same batch as
resetChat(). The chat list's scope effect strips remote rows, but as a
passive effect it commits after that batch, so the commit that makes the
new team current still showed the previous team's remote rows when a
Host of the same name exists in both teams.

resetChat() now drops remote rows through the same hideAllRemoteRows()
the scope effect uses, so the switch commit has none. The new test
drives handleEnsureTeamContext -> switchTeamForWorkspace and checks every
committed list, with menu data loaded and before the post-paint load.

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

Copy link
Copy Markdown
Contributor Author

Note from the PR #1112 final audit (for H11)

PR #1112 adds an in-memory transcript memo. On a memo reopen whose disk page matches the remembered one, the keep branch (useAgentChatController.ts:1241-1252 on 62f62def4) restores hasOlderMessages from the memo entry and does not probe again.

hasLocalMessagesBeyond (:232-241) maps a read failure to false. If that probe fails transiently on the first open, the memo stores hasOlderMessages=false, and every later reopen of that chat keeps it, so "Load older" does not come back until the entry is evicted or the memo is reset.

H11 changes that .catch. When it lands, please keep the keep branch in mind: either the probe failure must not be recorded in the memo, or the keep branch must re-probe when the remembered value came from a failed probe. A test with a probe that rejects once and then resolves would cover both paths.

@alfredolopez80

Copy link
Copy Markdown
Contributor Author

Re #1131 (comment) (H11 and the transcript memo keep branch)

The stuck hasOlderMessages=false is real on #1112 at 62f62def4, and this PR already removes it. Neither keep-branch change is needed, and I am not adding the rejects-once-then-resolves test.

Why the keep branch needs no change on this branch (0a5285724)

  • H11 changed the .catch in hasLocalMessagesBeyond to log the error and return true (useAgentChatController.ts:238-249). A failed probe now records hasOlderMessages=true in the memo, not false.
  • A reopen from the memo restores that value (:764), so "Load older" stays visible.
  • Clicking it reads and probes again. If no older history is left, it sets false (:1537, :1548), and the memo effect (:726-746) records the new value. If the read fails, the user gets the error toast (T-H11-2).
  • The remaining cost is a "Load older" that can show with no older history until it is clicked. That is the minimal-fix trade-off accepted for H11.

Why no new test

The proposed test checks two steps. An existing test fails as soon as either step breaks:

Step Existing test Mutation Result
A failed probe gives true T-H11-1 (switchToChat.unified.test.tsx:1026) .catch returns false red (EXIT=1)
The memo records and restores hasOlderMessages on reopen transcript memo case c, both Host kinds (switchToChat.transcriptMemo.test.tsx) the restore sets false instead of the memo value 2 failed / 42 passed (EXIT=1)

Every mutation that would make the combined test fail already turns one of these red, so it would add no failure the suite cannot already detect.

@jozer-rami jozer-rami left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 62f62def4..0a5285724: two findings to fix before merge, both in the comments below. I reproduced the first and confirmed the second by mutation.

  1. When the selected Host is held, ChatPage switches the user to a different agent's new blank chat (ChatPage.tsx). This is a behaviour regression compared with the base.
  2. The App-level heldAgentRefs wiring is not covered by any test (App.tsx). If it is broken, H1 and F1 stop working and the suite stays green.

I also checked the following and found it solid:

  • the H5 carry and per-Host rebuild
  • the R1-H1 teardown
  • H10, H11 and H16
  • the #1130 invalidations, each of which is killed by its own targeted mutation
  • the hold-store transitions
  • scope keying of the session catalog cache

The Retry path cannot bypass a confirmed revocation: a release needs a successful server read.

Comment thread desktop-app/ui/src/pages/ChatPage.tsx Resolved
Comment thread desktop-app/ui/src/App.tsx Resolved
alfredolopez80 and others added 2 commits October 16, 2026 14:22
When the active chat tab's Host is held (an uncertain or revoked access
check), ChatPage no longer auto-selects another Host. Before, a 403 on
the selected Host cleared the selection and ChatPage picked another Host
and opened a blank tab for it; on a Chat click while the focused chat's
Host was held, that competing select bumped the navigation epoch and
dropped the held Host's successful select after verification.

The controller exposes the active chat tab's Host as
focusedChatAgentRef through NavigationContext. The held-Host tests mount
the real controller and ChatPage for both paths, and an App test checks
that heldAgentRefs and focusedChatAgentRef reach NavigationContext,
changing one field per step so each memo dependency is covered.

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

Copy link
Copy Markdown
Contributor Author

@jozer-rami thanks for review 5547292745. Both findings were confirmed and are fixed in cdc29b6:

  1. Held Host on the chat tab (reply). ChatPage no longer auto-selects another Host while the active chat tab's Host is held. The user stays on that chat; Retry, or the verification a Chat click starts, selects it again. T-J1-1 and T-J1-2 in useAppController.heldHosts.test.tsx cover the 403 path and the Chat-click path, both mounting the real controller and ChatPage. Removing the guard turns both red.
  2. App wiring of heldAgentRefs (reply). New test in App.chatDrawer.test.tsx. It turns red when heldAgentRefs or focusedChatAgentRef is replaced with a constant and when either is dropped from the navValue dependencies. No production code was changed for this test.

Gates on cdc29b6: Desktop build, renderer typecheck, Prettier, style rules (0 errors) and the full Desktop suite (375 files, 4039 tests, 0 failures).

Not covered by this fix: the ChatPage inside the chat drawer, and a revoked Host that leaves the catalog (it then drops out of heldAgentRefs).

Base automatically changed from fix/issue-1091-chat-open-latency-v2 to dev October 16, 2026 21:11
alfredolopez80 and others added 3 commits October 16, 2026 23:24
#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>
Brings in the 55 commits merged into dev since 81c60e3. No conflicts,
and none of them touch files changed by this branch.

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

* origin/dev: (23 commits)
  chore: bump control-api and control-ui versions after merging dev
  docs: update docs for tool-lane hook guards and Host phase scoping
  docs: update docs for per-Host provider non-secret env
  docs: update docs for dev-mode docker compose
  docs: update docs for runtime OAuth install callback
  docs: update docs for monitoring starter Makefile and webhook-proxy health port
  docs: update docs for team delete conflicts
  docs: update docs for CIMD gateway route
  chore: leave CLA.md unchanged pending the CLA decision
  test(mcp-host): use non-PEM placeholder in vertex SA fixture
  fix(dev-mode): keep loopback URLs out of the public boundary check
  fix(control-api): preserve per-phase Host hook refs on hook upgrade
  refactor(mcp-host): return a fresh unavailable result per guarded short-circuit
  fix(mcp-host): resolve provider non-secret env from per-Host env
  fix(mcp-host): align tool-lane hooks with LLM-lane guards and scope hooks to Host phases
  fix(control-ui): read OAuth install callback from control-api at runtime
  chore: fix stale references, CRD descriptions and dead config keys
  fix(external-rest-api): expose the CIMD client metadata document publicly
  fix(control-ui): make admin delete, budget, and hook-install copy accurate
  fix(monitoring): build dashboards ConfigMap from the whole directory
  ...
…ions

The per-Host publication test builds both catalog arrival orders for two
rows with the same time, but after arrival it only checked the row count
and looked rows up with find(), so it stayed green with an arrival-order
comparator. Assert the visible order, which is the byUpdatedDesc tie-break
(Host, then chat id), in both arrival orders.

Removing the Host/id tie-break from byUpdatedDesc turns one of the two
cases red: expected [stateless-host, stateful-host] to deeply equal
[stateful-host, stateless-host].

Refs #1123 (item 16)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alfredolopez80 and others added 24 commits October 22, 2026 15:55
…e in every hold toast [R5-L2]

Only the verification toast read the last known name. A revocation that
arrived after the Host left the catalog went through the catalog name
alone and toasted the ref ("Access to agent-a was revoked."). The name
resolver now falls back to the last name a catalog of the current
principal gave the Host before it falls back to the ref, and the
verification toast uses the same resolver, read after its await.

Class-check: knownHostNamesRef.current.get( call sites -> 1 (verification toast) -> moved into principalCatalogName, 0 left; principalCatalogName( call sites -> 3 (heldHosts label, hold toast, verification toast) -> all get the map step; the label only lists Hosts still in the catalog, so the step changes nothing there; announceLoss -> 1 -> kept on the previous catalog, which named the Host
Repro-test: desktop-app/ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx T-R5L2-1 (red on db60944: "Access to agent-a was revoked." instead of "Access to Research Host was revoked."; red again with the map step removed, together with T-R4L2-1 and T-R4L2-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t [R5-L4]

A refresh started before a team switch is joined by the switch's own
refresh, so its team 1 catalog can land after the session is on team 2.
The catalog effect only kept that catalog's names out of the map; the
loss loop and the selected-Host branch still ran on it. A team 2 Host it
did not list was announced as "<team 1 name> is no longer available."
when held, and deselected when selected. The effect now returns right
after advancing the previous catalog when the catalog is not the current
principal's, before any name write, loss or selection decision.

Class-check: decisions in the catalog effect that read the current catalog -> 3 (name writes, held-Host loss loop, selected-Host branch) -> all behind the principal check now, previously 1 of 3; leftCatalog's previous/current userId and teamId equality -> 1 -> kept, with the current catalog the principal's it already requires the previous one to be the principal's; previousAccessCatalogRef advance -> 1 -> kept unconditional, so the next principal catalog is compared with the stale one and its team differs, which announces nothing
Repro-test: desktop-app/ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx T-R5L4-1 (red on acac80b: ["Billing Host is no longer available."] instead of []), T-R5L4-2 (red on acac80b: selectedAgent null instead of "agent-b"); both red again with the early return removed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d [R5-M1]

The chat drawer is never the stored active tab, so keepFocusedChat was
false for it. When a hold deselected the drawer's Host, ChatPage
auto-selected another Host and the user's next message went there.

The controller now remembers the Host the hold deselected. It is kept
while it is held and still listed, on the drawer (no chat tab active) or
on its own chat tab, and never under the Apps picker. The marker is
cleared on an authority scope reset and on the next non-null selection,
so after the user picks another Host the normal auto-select applies.

Class-check: keepFocusedChat readers -> 3 (App.tsx wiring,
NavigationContext type, ChatPage guard) -> all read the new value, no
change needed beyond the two comments; interruptedHostRef writes -> 3 (1
set in blockHostAccess on deselect, 1 clear on scope reset, 1 clear on a
non-null selection) -> complete; revocation toast condition
(activeChatAgentRefRef) -> 1 -> unchanged, T-A23-2 pins the picker case.
Repro-test: useAppController.chatDrawer.test.tsx T-R5M1-1, T-R5M1-2 and
T-R5M1-4 fail on 7fae3f6 (trail [null, 'agent-b'] instead of [null];
'agent-b' selected instead of null) and pass here; T-R5M1-3 is a pin
(green on both). Mutations: no interrupted-Host term -> M1-1, M1-2, M1-4
red; no listed term -> M1-3 and T-A9-P2 red; no selection clear -> M1-4
red; rule moved into ChatPage's mount -> M1-1, M1-2 red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The T-R4L2 tests listed a Host in team 2's catalog without a display
name, which the producer never does: every listed ref gets an
agentDisplayByName entry (desktop-app/src/appService.ts:4290-4292). A
test built on that shape could pass while a name from another scope
leaked, because the absent entry already forced the ref fallback.

The catalogs are now producer-shaped. Team 2 names agent-a "Team Two
Host", and the assertion that pins the scope clear and the principal
gate is made inside the team-switch window, before team 2's catalog
lands; after it lands, that catalog's own name is expected.

Class-check: T-R4L2 catalogs that were not producer-shaped -> 2 (the
team-2 catalogs of T-R4L2-1 and T-R4L2-2) -> replaced; T-R4L2-3
catalogs -> 2 -> moved to producerCatalog; T-A9-T1 is outside D5 and
unchanged.
Repro-test: test-only commit; the rebuilt tests are pinned by mutation
(they were not re-measured red on 0720afb). No knownHostNames clear
on the scope reset -> T-R4L2-1, T-R4L2-2 and T-R5L3-1 red; no principal
gate in the catalog effect -> T-R4L2-1, T-R4L2-2, T-R5L3-1, T-R5L4-1
and T-R5L4-2 red; verification toast reading agentDisplayByName first
-> T-R4L2-2 and T-R4L2-3 red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e [R5-L6]

The comment above isPrincipalCatalog cited a plan decision and promised
that the team clause would be removed. It now gives the reason the
clause exists: the main process builds each access catalog for the
trimmed session team and records that team on it
(desktop-app/src/appService.ts:4155, :4300), so a catalog whose team
differs from the session team belongs to another team.

Class-check: comments in desktop-app/ui/src that cite the team
retirement or a plan decision for the catalog gate -> 1 -> rewritten;
"Decision #6" display-name fallback comments -> 5, all from base
(460fe86 and earlier), not about the gate -> unchanged.
Repro-test: comment-only change; no behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…[R5-L1]

c96aeff trims principalTeamId, so currentTeamId (the chat-list cache
scope) now carries the trimmed session team, as the delete fence and
the producer already did (desktop-app/src/appService.ts:4155). The
witness in the padded me.teamId case of the delete-fence test still
expected the untrimmed value. The test's claim (the fence receives
"team-1") is unchanged; the witness now expects the trimmed scope.

Class-check: assertions on currentTeamId with a padded me.teamId -> 1
(useAppController.teamContext.test.tsx witness) -> updated; other
currentTeamId assertions use unpadded ids -> unchanged.
Repro-test: useAppController.teamContext.test.tsx "a padded me.teamId
is trimmed" failed on 6f13133 in the full Desktop run (expected
'team-1' to be '  team-1  ', 1 failed / 4122 passed) and passes here
(4/4).

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

T-R5L1-1 placed the hold after the display directory had resolved, so a
scope keyed by the directory team reset nothing the test could see: with
authorityScope back on currentTeamId the test stayed green (36/36). The
hold is now placed before the directory arrives, and the test asserts
that A is held, and listed in heldAgentRefs, both before and after the
arrival. The rest of the test (verification read, toast text, cached
catalog identity) is unchanged.

Class-check: tests that claim a state survives the post-paint directory
arrival -> 1 (T-R5L1-1) -> rewritten; other directory tests assert the
directory value only -> unchanged.
Repro-test: with authorityScope keyed by currentTeamId, T-R5L1-1
(me.teamId="") now fails at heldHosts.test.tsx:829 (expected false to be
true); on HEAD the file passes 36/36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R5-ASTRA-1, R5-ASTRA-3]

The agents query has one cache key for every principal, so after a team
switch the previous team's catalog stays cached until the new one lands.
During that window ChatPage auto-select and the nav-chat blank pick could
choose a Host of the previous team, and the first render of the new
principal could still list a Retry row from the previous catalog.

The controller now derives one current-principal catalog (same user and
trimmed session team, or null) and decides holds and automatic Host choice
from it. The new `selectableAgentRefs` (listed by that catalog, not held)
replaces `heldAgentRefs` in NavigationContext; ChatPage and the nav-chat
pick read it. While a team switch waits for its catalog the list is empty,
so nothing is chosen until the new team's catalog arrives.

Class-check: catalog readers in desktop-app/ui/src outside tests, 12
sites (`agentsData.agentNames` / `accessCatalog` in useAppController and
`useAgentsDataController()` consumers). Decided from the
current-principal catalog (5): heldAgentRefs, heldHosts labels,
liveAccessCatalogRef, ChatPage auto-select, nav-chat blank pick; the
catalog effect compares against the same value. Left on the raw cache
(display or data only, none decides a hold or a selection): AgentWorkspace
switcher (AgentWorkspace.tsx:84), FleetBoard.tsx:41,
HeaderActions/index.tsx:99, AgentsPage.tsx:13, McpServersPage.tsx:148,
the initialExperienceLoading boot splash, the useAgentChatController,
prewarm and activity inputs, and the ChatPage skeleton/empty render. Fixture changes (4 mocks): App.chatDrawer ChatPage
stub and wiring test, SidebarNav context, AgentWorkspace.chatScroll
context, ChatPage.autoSelect navigation mock. T-R4L2-2 and T-R5L3-1 now
expect no Retry rows inside the switch window.
Repro-test: useAppController.heldHosts.test.tsx T-R5A1-1a (ChatPage),
T-R5A1-1b (nav-chat), T-R5A3-1 (red on b8ee268; mutations: selectable
list from raw names, nav pick from raw names, held list from raw names,
each red).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gs [R5-REG-G612]

The catalog effect advanced its loss baseline before it checked that the
cached catalog belongs to the current principal. A catalog of the
previous team that lands after a team switch therefore became the
baseline. When the next team-2 catalog dropped a held team-2 Host, the
baseline either did not list it or listed it under another team, so the
loss toast never appeared.

The principal check now runs before the baseline advance, so a foreign
catalog names no Host, decides no selection or loss, and is never the
catalog a later loss is measured against. The userId/teamId equality in
leftCatalog stays: the first catalog of a new team is compared with the
previous team's baseline and stays silent.

Class-check: writers of previousAccessCatalogRef in desktop-app/ui/src
outside tests, 1 site (the catalog effect in useAppController); it now
advances only after the principal check. Readers of the baseline: 1
(leftCatalog in the same effect), unchanged. No fixture changes.
Repro-test: useAppController.heldHosts.test.tsx T-R5G612-1 (both
variants: the foreign catalog omits the Host / names it differently; red
on c2584cc), T-A9-T1 extended with a later team-2 loss that must be
announced (positive witness for the silent switch). Mutations, each red:
baseline advance before the principal check (T-R5G612-1), no principal
check (T-R5L4-1/2, T-R5G612-1, T-R5L3-1, T-R4L2-1/2), leftCatalog without
the userId/teamId equality (T-A9-T1), loss toast removed (T-A9-T1 and 15
others).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t [R5-REG-F1]

A hold of the selected Host marked it as the interrupted Host whatever
surface had selected it. A hold made from Agents, with no chat drawer
open, therefore kept a later Files drawer on the blank landing, although
another Host was listed and not held.

The controller now owns chatDrawerDesiredRef and App writes the drawer's
open intent into it on every render. The hold marks the Host only while
that intent is true. The intent, not the visibility, is used: a drawer
that the window width hides is still open, and when it returns it must
stay on the held Host instead of picking another one.

Class-check: writers of interruptedHostRef in desktop-app/ui/src outside
tests, 3 sites (blockHostAccess marker, scope reset, selection clear);
only the marker changes. Readers: 1 (the surface term), unchanged.
Writers of chatDrawerDesiredRef: 1 (App render). Fixture changes: the
App controller mocks in App.chatDrawer, App.deepLinks and
App.sandboxUiOwnership gain the ref; holdDrawerHost sets it to true after
opening Files, as App does.
Repro-test: useAppController.chatDrawer.test.tsx T-R5F1-1 (hold from
Agents, then a first Files drawer selects B without verifying A; red with
the f458ac1 marker rule at the keepFocusedChat assertion), T-R5F1-2
(hold while the width hides the drawer keeps it on A, a pin),
App.chatDrawer.test.tsx open-intent wiring test. Mutations, each red:
marker without the intent condition (T-R5F1-1), App without the write
(wiring test), App writing the visibility instead of the intent (wiring
test, at the width-suppressed assertion).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The drawer keep required the Apps picker to be closed. The picker leaves
the stored tab unchanged, so a drawer over Files stays mounted under it
(App drawerAvailable reads the stored tab). Opening the picker therefore
ended the keep, and the drawer's ChatPage selected another Host although
the interrupted Host was still held and listed.

The surface term now uses the same predicate as drawerAvailable: a
stored tab that is not a chat. The picker guard is removed, and so is
the disjunct for a focused tab of the interrupted Host, because
focusedChatAgentRef already wins in that case. The picker still clears
focusedChatAgentRef, so a chat tab under the picker is not kept.

Class-check: readers of nav.appsPickerActive in useAppController.ts, 2
(focusedChatAgentRef, unchanged; the vm field, unchanged); the surface
term was the third and is the only change. Readers of interruptedHostRef:
1 (keptChatAgentRef), unchanged.
Repro-test: useAppController.chatDrawer.test.tsx T-R5F2-1 (A held in the
drawer over Files, then the Apps picker opens: Files stays the stored
tab, the drawer stays mounted, keepFocusedChat stays true and no Host is
selected). Red on 64b56a5 at the keepFocusedChat assertion. Mutation:
restoring the picker guard turns it red at the same assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the branch up to date with origin/dev, mainly for the Node.js
toolchain updates.

Conflicts were resolved in favour of dev: dev already contains
9b9aaef and 40e9491, the versions this branch carried.

The dev test ui/src/__tests__/App.activationSeam.test.tsx failed 7/8 on
the merged tree with "TypeError: Cannot set properties of undefined
(setting 'current')" at App.tsx, because its controller mock had no
chatDrawerDesiredRef, which App writes on every render since this
branch's drawer-intent change. The mock now carries
chatDrawerDesiredRef: { current: false }, as the other App test mocks
do. Fixture only; no production change in this merge beyond dev.

Class-check: App test controller mocks that lack chatDrawerDesiredRef: 1 (App.activationSeam, new in dev); fixed in the fixture.
Repro-test: desktop-app/ui/src/__tests__/App.activationSeam.test.tsx (7 failed before, 8/8 after)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed [A29-ASTRA-1]

A conversation held in the full-screen chat tab sets no interrupted-Host
marker, because the drawer was not open (chatDrawerDesiredRef false).
After the user moves to Files and opens the drawer, App restores the last
chat tab with handleSelectChatAgent(A, { keepNavItem: true }). A is held,
so the call starts a verification without selecting A. The drawer's
ChatPage then sees no selection and no kept chat, selects B, and the
navigation epoch advances, so A's verification result is discarded.

When a held Host is requested with keepNavItem and nothing is selected,
the drawer is showing that Host's conversation. handleSelectChatAgent now
records it as the interrupted Host before the verification, as a hold
inside the drawer does, so keepFocusedChat holds and ChatPage waits for
the verification.

The test harness renderChatActionConsumer gains rerenderController().
App owns the drawer state next to the controller, so a drawer state
change re-renders the controller in the same batch; a test surface with
its own state does not, and calls rerenderController() to model that
render.

Class-check: handleSelectChatAgent calls with keepNavItem: 10 (App.tsx 9, useAppController openChat-in-drawer 1), all drawer surfaces. Three pass vm.selectedAgent and never reach the branch with nothing selected. The fix is in the one held branch they share. The Agents-hold case of R5-REG-F1 does not call it; T-R5F1-1 stays green.
Repro-test: desktop-app/ui/src/hooks/__tests__/useAppController.chatDrawer.test.tsx T-A29A1-1 (verified, denied): 2 failed before ("expected 'agent-b' to be null"), 2 passed after; mutation `if (false)` on the new branch: red, reverted and checked by sha256.

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

T-F3-2 (generic 403 and host_access_revoked) and T-R5L4-1/2 asserted
that no toast appears, with no toast from the same emitter in the same
test, so a dead emitter passed them as well as a correct guard.

T-F3-2 now holds the selected Host afterwards and expects exactly its
hold toast (Could not verify access / Access was revoked). The catalog
uses producer-shaped display names, so the negative filter matches the
display name as well as the ref, and matches "access" case-insensitively
because the revoked toast starts with "Access".

T-R5L4-1/2 now land team 2's own catalog listing B, then one without
B, and expect exactly one named loss toast for B after that catalog
(helper expectTeamTwoLossOfB, which reads only the toasts committed
after the drop).

Class-check: no-toast assertions in useAppController.heldHosts.test.tsx without a same-emitter positive phase: 4 (T-F3-2 x2, T-R5L4-1, T-R5L4-2), all given one. Production code unchanged.
Repro-test: desktop-app/ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx 41/41; mutation `if (false)` on the hold-toast condition in blockHostAccess: T-F3-2 2 failed ("expected [] to deeply equal [ Array(1) ]"); mutation removing pushToast from announceLoss: T-R5L4-1/2 2 failed (same); both reverted and checked by sha256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…A29-ASTRA-1, A29-ASTRA-2]

The renderer tsc check failed on the two previous commits: the drawer
test's placeholder callbacks threw, so TypeScript inferred `() => never`
and rejected the reassignment, and T-F3-2 typed its channel as `string`
where the IPC fixtures take `RpcChannel`. Vitest does not type-check,
so both suites were green.

Class-check: tsc errors in files touched by 6017ac9 and d57b3fb: 3 (chatDrawer.test.tsx:976, :1010; heldHosts.test.tsx:408), all fixed with explicit types; no other file reported.
Repro-test: `npx tsc -p ui/tsconfig.json --noEmit` UI_TSC_EXIT=2 before, 0 after; useAppController.chatDrawer.test.tsx 18/18, useAppController.heldHosts.test.tsx 41/41.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st is requested [A31-SOL-1]

6017ac9 set the interrupted-Host marker on every drawer request for a
held Host made with nothing selected. With A kept in the drawer and B
held in the background, requesting B from the drawer's switcher (denied)
moved the marker to B; when B then left the catalog the keep ended and
ChatPage selected another Host, abandoning A. A request is not a
selection, so the drawer request now sets the marker only when none is
set; a selection or a scope reset still clears it.

Class-check: writes of interruptedHostRef in useAppController.ts: 4 (scope reset :426 and selection effect :431 clear it; blockHostAccess :443 sets it only for the selected Host, after a selection that already cleared any marker; handleSelectChatAgent :1325 is the one that could replace a live marker, now guarded).
Repro-test: T-A31SOL1-1 in useAppController.chatDrawer.test.tsx red on 9619012 (:756 keepFocusedChat expected false to be true), green after (19/19); mutation removing `interruptedHostRef.current === null` turns only T-A31SOL1-1 red (18/19), reverted and checked by sha256.

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

A hold clears the selection to stop reads. The full-screen chat keeps the
Host it shows in its tab; the drawer kept it only through an inferred
interrupted-Host marker with four writers and a selection-driven clear,
plus the drawer open intent written by App. That marker could outlive the
conversation it described: with the drawer closed, ChatPage is unmounted,
so no selection cleared a marker whose Host had left the catalog, and on
reopen the restore of a held last tab was refused (SOL-2).

The drawer's Host is now the Host of the last chat selection
(lastChatHostRef), written by every chat selection and by a drawer request
for a held Host while the drawer does not already show another held,
listed Host. Over a non-chat tab, keepFocusedChat reads it; the keep rule
itself (held and listed) is unchanged.

Contract change, accepted: a hold made in Agents after a chat selection of
that Host now keeps it when the drawer opens over a non-chat tab, because
the drawer shows the last chat conversation.

Class-check: writes of lastChatHostRef in useAppController.ts: 3 (selectChatAgent entry :1187, every chat selection; handleSelectChatAgent held branch :1317, a drawer request for a held Host with nothing selected and no held, listed Host already recorded; authority scope reset :424, null). Removed: the marker write in blockHostAccess, the clear effect on nav.selectedAgent, chatDrawerDesiredRef (vm export and the App render write). Test fixtures: the chatDrawerDesiredRef mock line removed from App.activationSeam, App.sandboxUiOwnership, App.chatDrawer and App.deepLinks (1 each); 7 intent writes removed from useAppController.chatDrawer.test.tsx (on 2692891 the marker depended on that write, so the red-on-HEAD run kept it; the controller no longer reads it); the App wiring test "gives the controller the drawer open intent" deleted with the write it pinned.
Repro-test: T-A32SOL2-1 and T-A33-F1 in useAppController.chatDrawer.test.tsx red on 2692891 (trail=["agent-c"] expected 'agent-c' to be null; keepFocusedChat expected false to be true), green after (7 affected files, 190/190). Mutations, each reverted and checked by sha256: no write in selectChatAgent turns 8 drawer tests red, T-A33-F1 included; the "held and listed" clause removed turns T-A31SOL1-1 red; the held-branch write removed turns T-A32SOL2-1 red (the ASTRA-1 tests stay green: their Host was already recorded by its chat selection); the reader without the drawer Host turns 10 tests red, T-R5M1-1 included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…[A33-SOL-P2]

A chat selection records its Host as the drawer's Host. Closing that
Host's last chat tab dismissed its conversation but left the record, so a
drawer opened later over Files kept the held Host and showed a blank
landing instead of selecting a listed Host.

The tab strip now tells the controller which tab it closes and what
remains, before it commits the close. When the closed tab was the last
chat tab of the recorded Host, the record is cleared. It is cleared there
and not in an effect, because ChatPage's effect runs first and would read
the old value. Other chat tabs of that Host keep the record.

T-A33-F1 gains a positive phase on the same mounted drawer, so its "no
other Host selected" half fails when ChatPage never auto-selects. The
ChatPage comment names the Host it keeps and when the keep ends.

Class-check: production callers of closeWorkspaceTab: 1 (App.tsx handleCloseWorkspaceTab; the close shortcut calls the same handler); reconcileWorkspaceChatTab removes no tabs. Writes of lastChatHostRef: 4 (selectChatAgent entry, held branch of handleSelectChatAgent, authority scope reset, forgetClosedChatHost). Test fixtures: forgetClosedChatHost mock added to App.activationSeam, App.chatDrawer and App.sandboxUiOwnership (1 each). Session deletion with its chat tab open is not exercised.
Repro-test: T-A34-1 in useAppController.chatDrawer.test.tsx (the review's scratch journey: red on 9997d58, keepFocusedChat expected true to be false); green after. Mutations, each reverted and checked by sha256: early return in forgetClosedChatHost turns T-A34-1 and T-A33-F1 red; an unconditional return in ChatPage's keep guard turns T-A33-F1 red; removing the App call turns the activationSeam close assertion red. Desktop vitest 386 files / 4521 tests, both tsc, verify:electron: EXIT=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ents [A34-SOL-P3]

The keep comment in useAppController and the chat-drawer describe comment
still described the drawer Host as the Host a hold interrupted, or only as
the last chat selection. Both now name the accepted rule: the Host of the
last chat selection or of a drawer request for a held Host, until the user
closes that Host's last chat tab. Comments only.

Class-check: stale comments describing the drawer keep; 2 sites (useAppController.ts keep block, useAppController.chatDrawer.test.tsx describe), both fixed; no reference to interruptedHostRef or chatDrawerDesiredRef remains.
Repro-test: none (no behaviour change); Desktop vitest 386 files / 4521 tests EXIT=0, both tsc EXIT=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en [A36-R1]

While a Host is held, the chat tab or drawer keeps its conversation on
screen and Latest sessions shows a Retry row for it. Retry selected the
Host with no chatId, so a successful verification opened a new blank
chat for it instead of the conversation the user was reading. The Chat
nav click already passes the focused chat's chatId.

The held-Host entry of the kept Host now carries the chatId of that
conversation: the focused chat tab's, or for the drawer the last active
chat tab of that Host, which is the tab the drawer restores. Retry passes
it, so a successful Retry activates the existing tab.

Class-check: production consumers of heldHosts: 2 (SidebarNav Retry row and its focus effect; App passes it through NavigationContext). Retry is the only onSelectChatAgent call that reads heldHosts. Only the kept Host gets a chatId; other held Hosts keep {agentRef, label}. Test expectations updated for the added field: 8 exact heldHosts assertions (7 in useAppController.heldHosts.test.tsx, 1 in useAppController.chatDrawer.test.tsx), each with the chatId of its kept tab.
Repro-test: T-A36R1-1 in useAppController.heldHostRetry.test.tsx (real controller, ChatPage and SidebarNav): red on 0434707 (active tab chatId expected 'chat-1', received null); green after. Mutation: Retry without chatId turns it red at :124, reverted and checked by sha256. Focused files 4 / 83 tests and renderer tsc: EXIT=0.

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

The chat drawer takes its Host from the last chat selection, written at
the entry of selectChatAgent. Opening a notification full screen selects
the Host through its own fast and pending-selection paths, which never
wrote it. After the user went back to Files the drawer showed that Host,
but a hold of it ended the keep and ChatPage selected another Host.

The notification-open path now records its target Host after the drawer
branch, which already goes through handleSelectChatAgent.

Class-check: non-null nav.setSelectedAgent writers in useAppController.ts: 4. Two are inside selectChatAgent/handleSelectChatAgent (already recorded by W1); openAgentWorkspace (:1167) opens the Agents workspace, not a chat, and stays unrecorded (F1, T-R5F1-1); the notification-open path (:1469) is the one fixed here. The doc comment of lastChatHostRef ("written by every chat selection") is now true.
Repro-test: T-A36R2-1 in useAppController.notificationDrawer.test.tsx (real controller, ChatPage as the drawer over Files): red on 4a1a82d (keepFocusedChat expected true, received false, :110, with B held); green after. Mutation: remove the write turns it red at :110, reverted and checked by sha256. Focused files 4 / 65 tests: EXIT=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t [A36-R3]

T-F2-2 checks that a verification which fails after a team switch shows
no toast. It rejected only the team-1 display name, but after the switch
the toast would name the Host by its team-2 name or its ref, so the test
stayed green with the scope guard removed. It now compares every
"Could not verify access to" toast before and after the rejection.

Class-check: negative toast assertions on verifyHostAccess failures that match one display name: 1 (T-F2-2). T-F2-1 asserts the exact toast list.
Repro-test: T-F2-2 in useAppController.heldHosts.test.tsx. Mutation M09 (scope guard in verifyHostAccess replaced by `if (true)`): was green on 0434707, now red (received ["Could not verify access to agent-b."]); reverted and checked by sha256. File 41/41: EXIT=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keepFocusedChat doc now names the kept Host over a non-chat tab (the
last chat selection or drawer request). The resetChat comment says the
signed-out session load can call it with the scope unchanged. The
catalog-effect comment says the loop above announces a held Host's loss.

Class-check: comment-only edits at 3 sites (NavigationContext keepFocusedChat
doc, useAgentChatController resetChat invalidation, useAppController catalog
effect); no behaviour change.
Repro-test: none (comments); focused suites 4 files / 67 tests EXIT=0, renderer tsc EXIT=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the branch up to date with origin/dev so the T2 lane's ancestry
check passes. No conflicts; dev's 36 commits touch control-api,
control-ui, charts and docs, not desktop-app.

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

Copy link
Copy Markdown
Contributor Author

@zach88 thanks for round 5. I took your advice and wrote the design note before touching the code. All six findings were reproduced on 0720afb48 first, and the design was reviewed adversarially before any fix. Since then, the round-5 diff has had a regression review by five models, three diff reviews and a blind review. Current HEAD: d2e4cc57c, which includes a merge of dev (899a76016).

The design note

The note reduces the round to four structural facts. Each one became one change:

  1. The authority scope used the directory team, which arrives after paint. That cleared holds and names with no principal change (R5-L1). The scope now uses the trimmed session team, the same value the producer keys the catalog by (c96aeff2e).
  2. Three name sources, one of them without a principal check (R5-L2, R5-L3). There is now one resolver for the hold toast, the verify toast and the Retry labels. It reads the current principal's catalog, then the last known name, then the ref (db6094475, acac80bcf). The loss toast keeps its previous-catalog read: after item 3, that catalog is always the current principal's.
  3. The catalog effect acted on another principal's catalog (R5-L4). It now returns early for a catalog that is not the current principal's, before the loss loop and the selected-Host branch (7fae3f6e9). On the announcer you suggested, keyed by principal, Host and epoch: the early return keys it by principal, and the loop by Host. Leave, return, leave is announced again by construction. A separate announcer would add state without a case it handles that this does not.
  4. The keep rule read only the stored active tab (R5-M1). It now also covers the chat drawer over Files, settings or an app. The first version (eefce7b6e) used a marker that the hold set. The reviews below found three gaps in that marker, so 9997d58b4 replaced it: the drawer's Host is the Host of the last chat selection, a drawer request for a held Host, or a chat opened from a notification. That Host is recorded when the selection happens and forgotten when the user closes its last chat tab or the scope changes.

The decision table in the body now has the drawer column and rows 11 (leaves, then revoked) and 12 (scope change only through the directory team).

Must resolve

  • R5-M2 (fixtures not producer-shaped). 27e3eaa33: every catalog in T-R4L2-1/2/3 and in the new round-5 tests lists a display name for every ref, as appService.ts:4290-4291 does. Team 2 gives the same ref its own name (Team Two Host). The scope clear and the principal gate are now pinned inside the switch window, before team 2's catalog lands: there, heldHosts must be empty. Each of the three mutations you listed now turns its test red.
  • R5-L1 (verify toast names a listed Host by its ref). c96aeff2e, test T-R5L1-1 for me.teamId '' and 'team-1', plus a pin for the trim (T-R5L1-2). A review of the round-5 diff found that T-R5L1-1 placed the hold after the directory arrived, so its mutation stayed green. b8ee26894 moves the hold before the arrival; the mutation now turns the '' case red.
  • R5-L2 (late revocation after the loss names the ref). acac80bcf: the toast reads Access to Research Host was revoked. T-R5L2-1 requires [VERIFY_A, GONE_A, REVOKED_A].
  • R5-L6 (comment cites a plan). 6f1313342: the comment gives the reason for the team clause and no plan reference.

Optional

  • R5-M1 (drawer moves the chat to another Host). Fixed, as item 4 above. Tests T-R5M1-1..4 cover the hold in the drawer, a remount, the Host then leaving (pin), and a later selection of another Host.
  • R5-L3 (hold during the team switch uses team 1's name). Fixed by item 2; T-R5L3-1. This also showed that the Class-check: of 7346d8bf4 was wrong: the hold toast and the Retry labels did run with a stale catalog. db6094475 counts every agentDisplayName( call site: 2 in useAppController moved to the resolver, 5 elsewhere kept (chat notifications and chat list titles, which are not hold paths).
  • R5-L4 (late pre-switch catalog announces a loss). Fixed by item 3; T-R5L4-1. T-R5L4-2 covers the same catalog deselecting a team-2 conversation, which the early return also stops.
  • R5-L5 (mutation table under-lists the red set). Agreed. The per-round tables are replaced by one table re-measured on d2e4cc57c: 46 mutations, each applied alone to the 22 held-Host and drawer UI test files (482 tests, baseline green), with the complete red list from the JSON reporter and a sha256-checked restore after each run. 45 of 46 turn at least one test red; the remaining one (the scope reset's clear of the recorded drawer Host) is explained under the table. Your example now reads: removing the held-Host loss loop turns 14 red.
  • R4-L6 (two commits close two ids). 11146a14e and 0720afb48 stay as pushed; splitting them needs a force-push. Each later commit closes one finding.
  • R2-L3 (mcp-host fixture). Stays. dev still has cpSync(… errorOnExist: true …) at devGfsStoreFixture.ts:105, so without 154442d00 this PR's CI is red until another PR lands the same fix.
  • R3-L3. feat(mcp-host): durable model-step checkpoint and Retry model step (#1043) #1047 is still open; dev is merged here when it lands, and fb1cb2a96 then drops out of the diff.
  • R2-L4. Closed in writing, as you offered.

None of the optional items needed a separate issue.

After round 5

  • Regression review of b8ee26894 (GPT-6 Sol, GPT-6.1 Sol, GLM-5.3, Grok 4.7, DeepSeek V4.1 Flash). GPT-6 Sol found two regressions in round 5's own drawer change. Both were reproduced before any change. The two items I had listed as known in the earlier draft of this reply (ChatPage auto-selecting an old-team Host during a team switch, and heldHosts carrying the previous scope for one render) share a cause with them: the selection and hold readers took the cached catalog without checking its principal.
    • c2584cc27: holds, the Retry rows, ChatPage auto-select and the Chat nav pick read only the current principal's catalog. The two known items are fixed.
    • f458ac18f: another principal's catalog never becomes the loss baseline.
    • 64b56a5b6, 9498bc661: the two drawer regressions.
  • Diff reviews (GPT-6 Astra, then GPT-6 Sol). These found three gaps in the drawer marker: a conversation held full-screen and restored by the drawer, a denied request for a second held Host, and a Host that left the catalog while the drawer was closed. After the second gap I stopped patching the marker and replaced it (9997d58b4, item 4). The next review found that closing the Host's last chat tab did not end the keep (f550f0fe8).
  • Blind review of 043470733. Six reviewers had only the repository, the diff and the PR's goal. Three findings had a realistic user journey and are fixed:
    • a successful Retry opened a blank chat instead of the conversation on screen (4a1a82dda);
    • a chat opened from a notification did not record its Host (8348f9df4);
    • T-F2-2 stayed green with the scope guard removed (22b1d559d).
      The other findings need a timing window or a sequence no user journey produces. They are listed under "Not in this PR", with the pre-existing items the reviews found.

Validation on d2e4cc57c

  • Node 24.22.1, npm run verify:electron, tsc --noEmit (main and ui/): pass. Full Desktop suite: 388 files, 4523 tests, 0 failed, 0 pending. Each new test is red on the parent of its fix commit, or declared a pin.
  • Minikube T2: T1 684 tests / 91 files, MINIKUBE_T2_PASS, T2_EXIT=0.
  • Real-app E2E: all 10 specs green in one run. No spec holds a Host from the drawer or switches teams, so the round-5 changes are proven at Vitest level.

Details, test ids and the re-measured mutation table are in the updated PR body.

Brings in 4faf305 (ci: pin the build/test job to ubuntu-24.04 for
Playwright). ubuntu-latest now resolves to Ubuntu 26.04, where
Playwright 1.58.2 cannot install chromium, so the desktop-app and
external-rest-api build/test jobs failed at `npx playwright install`.

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

* origin/dev:
  docs(nginx-egress-proxy): cover nginx-unprivileged and the verifier test in the bump steps
  test(nginx-egress-proxy): run the TLS proxy with the generated securityContext
  fix(nginx): compare NGINX_VERSION per platform as a whole JSON value
  fix(nginx): reject glob tags, tagged newName, COPY --from and YAML node properties
  ci: pin the build/test job to ubuntu-24.04 for Playwright
  fix(test): skip nested live E2E packages in T0
  ci(nginx): pull NGINX through mirror.gcr.io and bound the NGINX jobs
  fix(nginx): verify digests under their full name, with an optional mirror
  fix(nginx): one grep scanner and a shared release policy for the image guard
  fix(nginx): make verify-nginx-image-digests.sh executable
  fix(nginx): require fixed NGINX releases and wait for TLS test log lines
  fix(e2e): drop one-iteration namespace loop flagged by shellcheck SC2066
  fix(nginx): upgrade platform NGINX images to 1.30.6 for October 2026 advisories
@zach88

zach88 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Took another pass at d2c99a58d (round 6, compared against 0720afb48). The naming and principal work landed well. All of round 5 is closed: R5-M1, R5-M2 and R5-L1 to R5-L6. Each fix has a test that goes red without it, and the mcp-host and scripts changes are out of the diff now that dev has them.

What's left is in the drawer keep and in the team-switch catalog, and most of it comes from one split. The Host of "the conversation on screen" lives in lastChatHostRef, which a closed list of writers updates. The chat id comes from lastActiveChatTabId, which belongs to another hook and means something else. Any writer of selectedAgent or of the tab that isn't on that list breaks the keep. This round found two more of those writers, and the drawer rule was rewritten nine times within the round (eefce7b6e through 8348f9df4).

Verdict: 🔴 Not ready to approve

  • Must resolve before approval: 6 (all open regressions; three change behaviour, three are smaller)
  • Optional / non-blocking: 7

This is my read from the review, not a formal GH approval.

Before another patch, I'd really write the short spec this time rather than a note. It should define one record {agentRef, chatId} for "the conversation on screen" (full-screen tab and drawer) and include:

  • a table of every writer of selectedAgent, the tab and the drawer, found by grep, not from memory;
  • the keep rule derived from that record;
  • what "focused chat" means for a tab with no chatId.

Items 1, 2 and the optional item 7 all fall out of that table. Item 3 belongs in it too.

Must resolve before approval

  1. [R6-M1] With the drawer open over Agents, holding the Host picked there moves the drawer to another Host. Regression from 9997d58b4.
    desktop-app/ui/src/hooks/useAppController.ts:1157-1171 (openAgentWorkspace) and :419-422 (drawerHost).
    openAgentWorkspace calls nav.setSelectedAgent(C) but never records C in lastChatHostRef. The drawer is available over any non-chat tab (App.tsx:940), so the drawer's ChatPage shows C while the record still says A.

    • Repro:

      1. A chat-1 full screen.
      2. Open Agents, then open the drawer.
      3. Pick C in Agents. The drawer now shows C.
      4. A read of C gets a generic 403.

      Result: Could not verify access to <C>., C deselected, keepFocusedChat=false, and the drawer's ChatPage auto-selects A (selection trail …,'agent-c',null,'agent-a').

    • The same test passes on 2692891d5, while App still wrote chatDrawerDesiredRef, and fails on the head.

    • This contradicts rows 4, 5 and 10 of "Keeping the chat tab" (row 10's writer list leaves out openAgentWorkspace). It also makes the Class-check: of 8348f9df4 ("openAgentWorkspace opens the Agents workspace, not a chat") untrue whenever the drawer is open.

    • There's no cheap patch. Recording in openAgentWorkspace every time breaks T-R5F1-1, and recording only while the drawer is open needs back the open intent that 9997d58b4 removed. That's why I'd settle it in the writer table.

  2. [R6-M2] A Chat click on a held Host's "New chat" tab reassigns that tab to another Host. Regression from 16f30000b (H1); it doesn't happen on the merge-base.
    desktop-app/ui/src/hooks/useAppController.ts:1363-1386 (handleNavSelect, the no-focused-chat branch) together with focusChatSection in useNavigationController.ts.
    focusChatSection returns null when the focused tab has no chatId. The branch then picks the latest non-held Host, and activateChatTab(B, null) reuses the active blank tab.

    • Repro:

      1. New chat for A, full screen: the tab is {agentRef: A, chatId: null}.
      2. An uncertain hold of A. This part is correct: keepFocusedChat=true and nothing is selected.
      3. Click Chat.

      Result: the same tab (same id) now belongs to B and B is selected. A is never verified.

    • On c8f137555 the auto-pick chose A again and verified it, so the tab stayed on A.

    • The body says the tab stays "until Retry or a Chat click verifies the Host". T-J1-2 covers that for tabs that have a chatId. A blank tab of a held Host should probably count as a focused chat here.

  3. [R6-M3] A team switch that joins an in-flight catalog read leaves nothing selectable, and nothing asks for the catalog again. Regression from c2584cc27.
    desktop-app/ui/src/hooks/useAppController.ts:402-410, :697-704 (getCatalogRefreshPromise), :1382, and desktop-app/ui/src/pages/ChatPage.tsx:26-40.

    • Repro:

      1. A catalog read is in flight (a route refresh, the post-paint refresh, or a hold's refresh).
      2. The user switches to team 2.
      3. refreshAuthenticatedData joins the pending promise, so the cache holds team 1's catalog.

      Result: {cachedTeam:"team-1", currentTeam:"team-2", selectable:[], selected:null, extraCatalogReads:0}. ChatPage and the Chat nav pick nothing, and the Chat route has no refresher.

    • The user gets out only by visiting Agents or Connectors, reloading, or clicking a team-1 Host still in the switcher.

    • On 0720afb48 that last path (403, hold, agentsData.refresh()) ran by itself, so the state recovered. c2584cc27 removed that way out.

    • "Not in this PR" (B-3) calls this pre-existing and says it lasts "until the current team's catalog lands". Neither part holds on this path, and the c2584cc27 message says the same thing.

    • Possible fixes: have the team switch not join the in-flight read (the A-3 root), or run one agentsData.refresh() per authorityScope while the cached catalog isn't the principal's. Please add a test that asserts the visible outcome: team 2's catalog arrives and the pick lands on a team-2 Host.

  4. [R6-L1] A Retry that resolves after the user navigated away reopens the tab they closed. Regression from 4a1a82dda.
    desktop-app/ui/src/hooks/useAppController.ts:1349-1356, desktop-app/ui/src/components/SidebarNav/index.tsx:449-456.
    Retry captures its options, including the chatId, at click time. navigationIntentEpoch only advances on chat or workspace selections, so a user who goes to Files during the verification, or closes the chat-1 tab, ends up back on full screen with chat-1 reopened when Retry resolves. The jump back to full screen was already there before this PR; reopening a closed tab is new with 4a1a82dda. Either advance the epoch on non-chat navigation and on tab close, or have Retry check that the tab is still in the strip. Read in the code, not reproduced.

  5. [R6-L5] T-R5G612-1 builds a foreign catalog the producer can't emit. Test regression from f458ac18f.
    desktop-app/ui/src/hooks/__tests__/useAppController.heldHosts.test.tsx:1130-1140.
    The foreign catalog comes from a refresh started after the switch. refreshAccessCatalog (desktop-app/src/appService.ts:4151-4155, :4299-4300) builds the catalog from the current this.me, so that refresh can't return teamId: 'team-1'. The switchWithStaleRefreshInFlight helper in the same file already models the real path, and using it would pin the same guard with a catalog the main process can actually produce.

  6. [R6-L6] The notification-drawer test uses a hand-written notification. Test regression from 8348f9df4.
    desktop-app/ui/src/hooks/__tests__/useAppController.notificationDrawer.test.tsx:88-94.
    The notification is built by hand and cast with as AppNotification, so the cast hides any missing fields. Derive it from useChatNotifications or its producer. The test also has no positive phase showing that the drawer does move to another Host once the keep ends (T-A33-F1 has one), so it can't fail for the right reason on its own.

Optional (non-blocking)

These can land as follow-ups or at your call. None of them blocks the merge. Item 7 is a Medium that should fall out of the same spec as items 1 and 2.

  1. [R6-M4] Retry in the drawer doesn't return to the conversation on screen.
    desktop-app/ui/src/hooks/useAppController.ts:429-433 (the drawerHost branch of keptChatId).
    The chat id comes from lastActiveChatTabId, which only moves when a chat tab becomes the full-screen active tab (useNavigationController.ts:58-63). Nothing the drawer opens ever does that.

    • Repro 1:

      1. A chat-1 full screen.
      2. Open Files, then the drawer.
      3. Open A chat-2 in the drawer.
      4. Hold A.

      Retry goes to chat-1 (expected 'chat-1' to be 'chat-2').

    • Repro 2: the last full-screen tab belongs to B and the drawer opens A chat-2. Retry opens a blank chat (expected null to be 'chat-2'), which is the symptom A36-R1 set out to fix.

    • With else if (false && …) on that branch, the 38 files and 381 tests of the area stay green: T-A36R1-1 covers only the focused tab. The body line "…the focused chat tab's or the drawer's" has no test behind it.

  2. [R6-L2] A notification opened in the drawer for a held Host is verified before it's recorded.
    desktop-app/ui/src/hooks/useAppController.ts (openAgentConversationTarget with stayInDrawer).
    If nothing is selected and the drawer's Host isn't held, the drawer's ChatPage can auto-select another Host during that await, and it stays there if the verification is denied. The equivalent drawer request through handleSelectChatAgent records first. This contradicts row 10's "notification open". Read in the code, not reproduced.

  3. [R6-L3] Under the Apps picker, the Retry row has no chatId.
    desktop-app/ui/src/hooks/useAppController.ts:429-433.
    keptChatAgentRef is null under the picker, so a successful Retry there opens a blank chat instead of going back to the tab under the picker. It's arguable, since the body says no chat is focused under the picker for the keep. Still, either declare what Retry does there or carry the chatId.

  4. [R6-L4] The userId clause of the principal gate has no test.
    desktop-app/ui/src/hooks/useAppController.ts:335.
    With agentsData.accessCatalog.userId === auth.me?.id && removed, the 71 tests across the five held-Host suites stay green, and the mutation table doesn't list it. A sibling of T-R5L4-1 with a late catalog from another userId (logout, then a login as someone else) would pin it. Declaring it hardening in the body is fine too.

  5. [R6-L7] A comment cites a ticket.
    desktop-app/ui/src/hooks/useAppController.ts:522: // #1130: …. Keep the reason inline and drop the ticket number.

  6. [R6-L8] Body claims that no longer hold.
    These are the Drawer cells of rows 4, 5 and 10, the Retry sentence in "Keeping the chat", and B-3 under "Not in this PR" (see items 1, 3 and 7). They'll mostly change with those fixes. Whatever stays declared should state the exact effect.

  7. [R4-L6] Commits that close two ids. Still open since round 4.
    c2584cc27 ([R5-ASTRA-1, R5-ASTRA-3]) and 961901294 close two ids each again. I understand you don't want to force-push, so for the pushed commits I'm fine closing this in writing. For new commits, please keep one id per commit.

What checked out clean

  • Round 5 is fully closed.
    • The single name resolver covers every hold toast and Retry label.
    • The team clause, the loss baseline and the session-team scope each turn tests red under their own mutation.
    • The fixtures in T-R4L2-* use producer-shaped catalogs now.
    • The re-measured mutation table matches my runs, except one row that shows one red test fewer than I get.
  • Security looks good.
    • Retry, notification opens and the return to the conversation only select a Host after verifyHostAccess succeeds with the same scope and epoch.
    • lastChatHostRef never sets selectedAgent.
    • The scope reset clears holds, the last known names and the drawer record.
    • In the render between a scope change and its effect, principalCatalog is null, so nothing from the previous principal shows.
    • The me.teamId trim matches the main process (appService.ts:575, :1664, :1830, :4300).
    • Names render as text or aria-label, and the logs carry no secrets.
  • The dev merges, including refactor(desktop-app): own the embedded app view in a state machine #1148's embed ownership, didn't break the wiring.
    • forgetClosedChatHost runs before activateAndLaunch's flushSync.
    • The activation seam goes through handleSelectChatAgent.
    • The App mocks carry the new field.
  • Smaller checks.
    • Tab close has a single path (closeWorkspaceTab, keyboard shortcut included), so the A33 clear has no siblings.
    • Both pickLatestAgent( call sites read selectableAgentRefs.
    • I spot-checked four new tests against the parent of their fix (c96aeff2e, c2584cc27, 4a1a82dda, 8348f9df4); all four go red on the assertion, not on setup.
    • The drawer writes from 8348f9df4, 4a1a82dda (focused tab) and 9997d58b4 (drawer request) each have a test that goes red without them.
  • Suites: the full renderer suite is green at d2c99a58d (250 files, 2686 tests), and tsc -p ui/tsconfig.json --noEmit exits 0. I didn't run Prettier or lint.

alfredolopez80 and others added 2 commits October 23, 2026 15:56
Brings in #1178 (control-api: return secret identity in the
mcp-secrets list). Two control-api files; no overlap with this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants