From 7e7fe9048327ba868d6a485018bf450d96d0fe05 Mon Sep 17 00:00:00 2001 From: jsonet <41315967+etjson@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:17:38 +0800 Subject: [PATCH] fix(chat): isolate concurrent session events and workspace dialog i18n (#351) * feat: per-session workspace with folder picker, HERMES_HOME support, esbuild fix * fix(chat): isolate concurrent session events and workspace dialog i18n Two user-visible bugs are fixed here: 1. Workspace dialog title showed the raw i18n key 'chat.setWorkspaceTitle' because the key was never added to en.ts / zh.ts. The dialog is opened from ChatPanel.vue but only 'setWorkspace' existed. Add the missing 'setWorkspaceTitle' translation in both locales. 2. With two concurrent runs the assistant text from session A would show up in session B (and vice versa). The /chat-run namespace uses a single shared Socket.IO connection on the client; every startRunViaSocket() call registers its own listeners on the same socket. The server fans events out via 'session:' rooms, but a single socket can be in multiple rooms at once and there was no per-event filtering on the client. Each run's closure captured its own sid and wrote into the wrong session. The server already tags every payload with session_id, so the fix is a guard inside handleEvent() that drops events whose session_id does not match this run's body.session_id. Untagged events are still accepted for backwards compatibility. 3. Also fix a related crash where setting a workspace on a session that had not been persisted yet (no first message sent) threw because the row did not exist. Create the row on demand inside setWorkspace controller. * fix: upgrade esbuild to 0.27+ for vite 8 compatibility --------- Co-authored-by: ekko --- package.json | 1 + packages/client/src/api/hermes/chat.ts | 16 +- packages/client/src/api/hermes/sessions.ts | 13 + .../src/components/hermes/chat/ChatPanel.vue | 55 +++- .../components/hermes/chat/FolderPicker.vue | 281 ++++++++++++++++++ packages/client/src/i18n/locales/en.ts | 6 + packages/client/src/i18n/locales/zh.ts | 6 + packages/client/src/stores/hermes/chat.ts | 2 + .../server/src/controllers/hermes/sessions.ts | 69 +++++ packages/server/src/db/hermes/schemas.ts | 1 + .../server/src/db/hermes/session-store.ts | 11 +- packages/server/src/routes/hermes/sessions.ts | 2 + .../src/services/hermes/chat-run-socket.ts | 11 + .../src/services/hermes/hermes-profile.ts | 2 +- 14 files changed, 468 insertions(+), 8 deletions(-) create mode 100644 packages/client/src/components/hermes/chat/FolderPicker.vue diff --git a/package.json b/package.json index 424b6ac..485dfbe 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "socket.io-client": "^4.8.3" }, "devDependencies": { + "esbuild": "^0.27.0", "@koa/bodyparser": "^5.0.0", "@koa/cors": "^5.0.0", "@koa/router": "^15.4.0", diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 4cc654d..1ce6bd6 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -131,14 +131,26 @@ export function startRunViaSocket( socket.off('usage.updated', onUsageUpdated) } - // All event handlers share the same cleanup logic + // All event handlers share the same cleanup logic. + // IMPORTANT: The Socket.IO connection is shared across all in-flight runs + // (single namespace, single socket). When two sessions run concurrently, + // every `startRunViaSocket()` call registers its own `message.delta` / + // `tool.*` / `run.*` listeners on the SAME socket, so each event would + // fan out to every listener and corrupt the wrong session's transcript. + // The server tags every payload with `session_id`; we filter here so each + // run only sees its own events. We also accept untagged events (for + // backwards compatibility) when no session_id was provided in the request. + const expectedSid = body.session_id const handleEvent = (event: RunEvent) => { if (closed) return + // Filter events by session_id to prevent cross-session contamination + if (expectedSid && event.session_id && event.session_id !== expectedSid) { + return + } try { onEvent(event) } finally { if (event.event === 'run.completed' || event.event === 'run.failed') { - console.log('[startRunViaSocket] Run completed/failed, calling cleanup and onDone', event.event) cleanup() onDone() } diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index eeba736..d47bab2 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -20,6 +20,7 @@ export interface SessionSummary { estimated_cost_usd: number actual_cost_usd: number | null cost_status: string + workspace?: string | null } export interface SessionDetail extends SessionSummary { @@ -95,6 +96,18 @@ export async function renameSession(id: string, title: string): Promise } } +export async function setSessionWorkspace(id: string, workspace: string | null): Promise { + try { + await request(`/api/hermes/sessions/${id}/workspace`, { + method: 'POST', + body: JSON.stringify({ workspace: workspace || '' }), + }) + return true + } catch { + return false + } +} + export interface UsageStatsResponse { total_input_tokens: number total_output_tokens: number diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index de5c222..58301b7 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -1,5 +1,5 @@