From 42bd4368aef12be890a74b749ae04d009c0a4412 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 12:49:09 -0400 Subject: [PATCH] fix(desktop): sidebar status indicators lag for background sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar working dot didn't update for background sessions until the user opened them. Two coupled causes: 1. The gateway's session.info event payload omitted stored_session_id, so the desktop app had no way to map a background session's runtime id to its stored id. Without the stored id, setSessionWorking(null, ...) was a no-op — the $workingSessionIds atom never updated. 2. The running→busy transition in the session.info handler was gated on `apply` (active session only). The gate correctly scopes view-only side effects (setCurrentModel, setCurrentCwd, etc.) to the focused chat, but the per-session busy state drives the sidebar indicator and must reach every session. updateSessionState only mutates the per-runtime cache entry, and syncSessionStateToView already guards the view publish to the active session, so ungating is safe. Fix: add stored_session_id to _session_info() in tui_gateway/server.py, add the field to GatewayEventPayload, pass it to updateSessionState in the session.info handler, and ungate the running→busy transition. --- .../src/app/chat/sidebar/session-row.tsx | 5 +-- .../hooks/use-message-stream/gateway-event.ts | 36 +++++++++++++------ apps/desktop/src/lib/chat-messages.ts | 4 +++ tui_gateway/server.py | 1 + 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index c6f8ebc7a332..67b45607cf3c 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -313,10 +313,11 @@ const DOT_VARIANTS: Record = { role: 'status' }, // Pulsing gray — a terminal(background=true) process is alive while the LLM - // is idle. Gray (not accent) reads as "something chugging along". + // is idle. Gray (not accent) reads as "something chugging along". Brighter + // than muted-foreground so it's visible against the sidebar surface. background: { ariaLabel: r => r.backgroundRunning, - className: `${DOT_BASE} bg-muted-foreground/50 ${PING} before:bg-muted-foreground/50 before:opacity-50`, + className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`, role: 'status', title: r => r.backgroundRunning }, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 0d585b017e40..ebef5492070f 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -218,17 +218,30 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { } if (sessionId && hasStatePatch) { - updateSessionState(sessionId, state => ({ - ...state, - ...statePatch, - branch: statePatch.branch ?? state.branch, - cwd: statePatch.cwd ?? state.cwd - })) + updateSessionState( + sessionId, + state => ({ + ...state, + ...statePatch, + branch: statePatch.branch ?? state.branch, + cwd: statePatch.cwd ?? state.cwd + }), + payload?.stored_session_id || undefined + ) } - if (apply) { - if (runningChanged && sessionId) { - updateSessionState(sessionId, state => { + // The running→busy transition must reach EVERY session, not just the + // active one. The `apply` gate above correctly scopes view-only side + // effects (setCurrentModel, setCurrentCwd, etc.) to the focused chat, + // but the per-session busy state is what drives the sidebar working + // indicator — a background session's turn start/finish must update + // its dot without the user opening it. updateSessionState only + // mutates the per-runtime cache entry, and syncSessionStateToView + // guards the view publish to the active session, so this is safe. + if (runningChanged && sessionId) { + updateSessionState( + sessionId, + state => { const busy = Boolean(payload!.running) if (state.busy === busy && (busy || !state.awaitingResponse)) { @@ -255,8 +268,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { streamId: null, turnStartedAt: null } - }) - } + }, + payload?.stored_session_id || undefined + ) } if (payload?.usage && (!explicitSid || isActiveEvent)) { diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index c622b90d85e7..9488bd4cb4f0 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -79,6 +79,10 @@ export type GatewayEventPayload = { // session.title (live auto-title push) — stored session id + generated title session_id?: string title?: string + // session.info — the stored (durable) session id for this runtime session. + // Lets the desktop app map runtime→stored for background sessions it hasn't + // opened, so the sidebar working indicator updates without opening the chat. + stored_session_id?: string // moa.reference / moa.aggregating (Mixture of Agents per-model relay) label?: string index?: number diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 47b10a0d4be0..2f92833d33b3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3656,6 +3656,7 @@ def _session_info(agent, session: dict | None = None) -> dict: "personality": str(personality or ""), "running": bool((session or {}).get("running")), "title": _session_live_title(session or {}, session_key) if session_key else "", + "stored_session_id": session_key or "", "desktop_contract": DESKTOP_BACKEND_CONTRACT, "version": "", "release_date": "",