From f9ffe0bc3f619fc2100bd3e77622090e9c794603 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Fri, 19 Jun 2026 18:54:27 +0700 Subject: [PATCH] fix(desktop): resume stored session id on notification click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native notifications (approval / sudo / secret / clarify) are tagged with the gateway *runtime* session id — the key under which the session lives in the gateway's in-memory `_sessions` map and the id every event carries (`tui_gateway/server.py` `_emit(event, sid, ...)`). The chat route, however, is keyed by the *stored* session id (`stored_session_id`), which is a different value: a new chat gets its runtime id immediately but its stored id only once the first turn persists. `onFocusSession` navigated straight to `sessionRoute()`, so clicking a notification (e.g. an approval prompt) sent the route-resume path a runtime id where it expects a stored id. `useRouteResume` then resumed it as a stored session -> REST `/api/sessions/` 404 "session not found", and the running session was navigated away, which the user experiences as the session being destroyed. Translate runtime -> stored before navigating via the existing `runtimeIdByStoredSessionId` map (new `storedSessionIdForNotification` helper), falling back to the id as-is when no mapping is known. The Approve/Reject notification button path is untouched: `approval.respond` is routed by the runtime id (`_sess()` -> `_sessions[session_id]`), so it must keep carrying the runtime id. --- apps/desktop/src/app/desktop-controller.tsx | 11 ++++++--- apps/desktop/src/lib/session-ids.ts | 26 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/lib/session-ids.ts diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 05dfbbc764f..c2523bf3654 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -20,6 +20,7 @@ import { MESSAGING_SESSION_SOURCE_IDS, normalizeSessionSource } from '../lib/session-source' +import { storedSessionIdForNotification } from '../lib/session-ids' import { latestSessionTodos } from '../lib/todos' import { setCronFocusJobId, setCronJobs } from '../store/cron' import { @@ -276,16 +277,20 @@ export function DesktopController() { } }, []) - // Notification click: the main process already focused the window; jump to its session. + // Notification click: the main process already focused the window; jump to its + // session. Notifications are tagged with the gateway *runtime* session id, but + // the chat route is keyed by the *stored* id — navigating with the runtime id + // resumes a non-existent stored session ("session not found") and strands the + // user. Translate runtime -> stored before navigating. useEffect(() => { const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => { if (sessionId) { - navigate(sessionRoute(sessionId)) + navigate(sessionRoute(storedSessionIdForNotification(sessionId, runtimeIdByStoredSessionIdRef.current))) } }) return () => unsubscribe?.() - }, [navigate]) + }, [navigate, runtimeIdByStoredSessionIdRef]) // Notification action button (Approve/Reject) — resolve in place, no navigation. useEffect(() => { diff --git a/apps/desktop/src/lib/session-ids.ts b/apps/desktop/src/lib/session-ids.ts new file mode 100644 index 00000000000..c97cadc2628 --- /dev/null +++ b/apps/desktop/src/lib/session-ids.ts @@ -0,0 +1,26 @@ +// The gateway tags every event — and therefore every native notification — +// with the *runtime* session id (the key under which the session lives in the +// gateway's in-memory `_sessions` map). The chat route, however, is keyed by +// the *stored* session id (`stored_session_id`), which is a different value: +// a brand-new chat gets a runtime id immediately but its stored id is assigned +// when the first turn persists. Navigating to a runtime id therefore tries to +// resume a stored session that does not exist ("session not found") and +// strands the user, who experiences it as the running session being destroyed. +// +// `runtimeIdByStoredSessionId` maps stored -> runtime; this resolves the +// reverse so notification-click navigation lands on the real route. The id is +// returned unchanged when no mapping is known — it may already be a stored id +// (e.g. a notification for a session this window never opened), in which case +// the normal resume/REST lookup handles it. +export function storedSessionIdForNotification( + id: string, + runtimeIdByStoredSessionId: ReadonlyMap +): string { + for (const [storedId, runtimeId] of runtimeIdByStoredSessionId) { + if (runtimeId === id) { + return storedId + } + } + + return id +}