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:<id>' 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 <fqsy1416@gmail.com>
This commit is contained in:
jsonet
2026-04-30 20:17:38 +08:00
committed by GitHub
parent dac9006b3e
commit 7e7fe90483
14 changed files with 468 additions and 8 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { renameSession } from '@/api/hermes/sessions'
import { renameSession, setSessionWorkspace } from '@/api/hermes/sessions'
import { useChatStore, type Session } from '@/stores/hermes/chat'
import { useSessionBrowserPrefsStore } from '@/stores/hermes/session-browser-prefs'
import { NButton, NDropdown, NInput, NModal, NTooltip, useMessage } from 'naive-ui'
@@ -7,6 +7,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { getSourceLabel } from '@/shared/session-display'
import { copyToClipboard } from '@/utils/clipboard'
import FolderPicker from './FolderPicker.vue'
import ChatInput from './ChatInput.vue'
import ConversationMonitorPane from './ConversationMonitorPane.vue'
import MessageList from './MessageList.vue'
@@ -183,6 +184,7 @@ const contextSessionPinned = computed(() =>
const contextMenuOptions = computed(() => [
{ label: t(contextSessionPinned.value ? 'chat.unpin' : 'chat.pin'), key: 'pin' },
{ label: t('chat.rename'), key: 'rename' },
{ label: t('chat.setWorkspace'), key: 'workspace' },
{ label: t('chat.copySessionId'), key: 'copy-id' },
])
@@ -207,6 +209,11 @@ function handleContextMenuSelect(key: string) {
}
if (key === 'copy-id') {
copySessionId(contextSessionId.value)
} else if (key === 'workspace') {
const session = chatStore.sessions.find(s => s.id === contextSessionId.value)
workspaceSessionId.value = contextSessionId.value
workspaceValue.value = session?.workspace || ''
showWorkspaceModal.value = true
} else if (key === 'rename') {
const session = chatStore.sessions.find(s => s.id === contextSessionId.value)
renameSessionId.value = contextSessionId.value
@@ -237,6 +244,26 @@ async function handleRenameConfirm() {
}
showRenameModal.value = false
}
const showWorkspaceModal = ref(false)
const workspaceValue = ref('')
const workspaceSessionId = ref<string | null>(null)
async function handleWorkspaceConfirm() {
if (!workspaceSessionId.value) return
const ok = await setSessionWorkspace(workspaceSessionId.value, workspaceValue.value || null)
if (ok) {
const session = chatStore.sessions.find(s => s.id === workspaceSessionId.value)
if (session) session.workspace = workspaceValue.value || null
if (chatStore.activeSession?.id === workspaceSessionId.value) {
chatStore.activeSession.workspace = workspaceValue.value || null
}
message.success(t('chat.workspaceSet'))
} else {
message.error(t('chat.workspaceSetFailed'))
}
showWorkspaceModal.value = false
}
</script>
<template>
@@ -330,6 +357,18 @@ async function handleRenameConfirm() {
/>
</NModal>
<NModal
v-model:show="showWorkspaceModal"
preset="dialog"
:title="t('chat.setWorkspaceTitle')"
:positive-text="t('common.ok')"
:negative-text="t('common.cancel')"
style="width: 520px"
@positive-click="handleWorkspaceConfirm"
>
<FolderPicker v-model="workspaceValue" />
</NModal>
<div class="chat-main">
<header class="chat-header">
<div class="header-left">
@@ -340,6 +379,7 @@ async function handleRenameConfirm() {
</NButton>
<span class="header-session-title">{{ headerTitle }}</span>
<span v-if="activeSessionSource" class="source-badge">{{ getSourceLabel(activeSessionSource) }}</span>
<span v-if="chatStore.activeSession?.workspace" class="workspace-badge" :title="chatStore.activeSession.workspace">📁 {{ chatStore.activeSession.workspace.split('/').pop() || chatStore.activeSession.workspace }}</span>
</div>
<div class="header-actions">
<!-- chat/live mode toggle hidden -->
@@ -710,4 +750,17 @@ async function handleRenameConfirm() {
padding: 16px 12px 16px 52px;
}
}
.workspace-badge {
font-size: 11px;
color: $text-muted;
background: rgba(255, 255, 255, 0.05);
padding: 2px 8px;
border-radius: 4px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: default;
}
</style>