Files
Hermes-ui/packages/client/src/components/hermes/chat/FolderPicker.vue
T
jsonetandekko 7e7fe90483 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>
2026-04-30 20:17:38 +08:00

282 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { NSpin } from 'naive-ui'
import { request } from '@/api/client'
interface FolderEntry {
name: string
path: string
fullPath: string
}
interface FolderListResponse {
base: string
current: string
folders: FolderEntry[]
}
/** Flat display node for rendering tree without recursion */
interface FlatNode {
folder: FolderEntry
depth: number
isExpanded: boolean
isLoading: boolean
hasChildren: boolean | null // null = unknown
}
const props = defineProps<{
modelValue: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const loading = ref(false)
const basePath = ref('')
const folders = ref<FolderEntry[]>([])
const expandedPaths = ref<Set<string>>(new Set())
const childrenCache = ref<Map<string, FolderEntry[]>>(new Map())
const loadingPaths = ref<Set<string>>(new Set())
const selectedPath = ref(props.modelValue || '')
watch(() => props.modelValue, (v) => { selectedPath.value = v || '' })
async function loadFolders(subPath = ''): Promise<FolderListResponse | null> {
try {
const query = subPath ? `?path=${encodeURIComponent(subPath)}` : ''
return await request<FolderListResponse>(`/api/hermes/workspace/folders${query}`)
} catch {
return null
}
}
onMounted(async () => {
loading.value = true
const res = await loadFolders()
if (res) {
basePath.value = res.base
folders.value = res.folders
}
loading.value = false
})
async function toggleExpand(folder: FolderEntry) {
if (expandedPaths.value.has(folder.path)) {
expandedPaths.value.delete(folder.path)
expandedPaths.value = new Set(expandedPaths.value)
return
}
expandedPaths.value.add(folder.path)
expandedPaths.value = new Set(expandedPaths.value)
if (!childrenCache.value.has(folder.path)) {
loadingPaths.value.add(folder.path)
loadingPaths.value = new Set(loadingPaths.value)
const res = await loadFolders(folder.path)
childrenCache.value.set(folder.path, res?.folders || [])
childrenCache.value = new Map(childrenCache.value)
loadingPaths.value.delete(folder.path)
loadingPaths.value = new Set(loadingPaths.value)
}
}
function selectFolder(folder: FolderEntry) {
const fullPath = `${basePath.value}/${folder.path}`
selectedPath.value = fullPath
emit('update:modelValue', fullPath)
}
function selectBase() {
selectedPath.value = basePath.value
emit('update:modelValue', basePath.value)
}
/** Build a flat list by DFS traversal of expanded nodes */
const flatNodes = computed<FlatNode[]>(() => {
const result: FlatNode[] = []
function traverse(entries: FolderEntry[], depth: number) {
for (const folder of entries) {
const isExpanded = expandedPaths.value.has(folder.path)
const isLoading = loadingPaths.value.has(folder.path)
const children = childrenCache.value.get(folder.path)
result.push({
folder,
depth,
isExpanded,
isLoading,
hasChildren: children ? children.length > 0 : null,
})
if (isExpanded && children && children.length > 0) {
traverse(children, depth + 1)
}
}
}
traverse(folders.value, 0)
return result
})
</script>
<template>
<div class="folder-picker">
<div v-if="loading" class="folder-picker-loading">
<NSpin size="small" />
</div>
<div v-else class="folder-tree">
<!-- Base path as root -->
<div
class="folder-item root"
:class="{ selected: selectedPath === basePath }"
@click="selectBase"
>
<span class="folder-icon">📂</span>
<span class="folder-name">{{ basePath || '/' }}</span>
</div>
<!-- Flat rendered tree -->
<div
v-for="node in flatNodes"
:key="node.folder.path"
class="folder-item"
:class="{ selected: selectedPath === `${basePath}/${node.folder.path}` }"
:style="{ paddingLeft: `${12 + node.depth * 16}px` }"
>
<span class="folder-expand" @click.stop="toggleExpand(node.folder)">
<template v-if="node.isLoading"></template>
<template v-else>{{ node.isExpanded ? '▼' : '▶' }}</template>
</span>
<span class="folder-icon" @click="selectFolder(node.folder)">📁</span>
<span class="folder-name" @click="selectFolder(node.folder)">{{ node.folder.name }}</span>
</div>
<!-- Empty children indicator for expanded folders with no children -->
<template v-for="node in flatNodes" :key="'empty-' + node.folder.path">
<div
v-if="node.isExpanded && !node.isLoading && node.hasChildren === false"
class="folder-item empty"
:style="{ paddingLeft: `${28 + node.depth * 16}px` }"
>
<span class="folder-empty-text"></span>
</div>
</template>
<div v-if="folders.length === 0 && !loading" class="folder-empty">
暂无工作区文件夹
</div>
</div>
<!-- Selected path display -->
<div v-if="selectedPath" class="folder-selected">
<span class="folder-selected-label">已选择</span>
<span class="folder-selected-path">{{ selectedPath }}</span>
</div>
</div>
</template>
<style scoped lang="scss">
.folder-picker {
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
}
.folder-picker-loading {
display: flex;
justify-content: center;
padding: 24px;
}
.folder-tree {
font-size: 13px;
}
.folder-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.06);
}
&.selected {
background: rgba(64, 158, 255, 0.15);
outline: 1px solid rgba(64, 158, 255, 0.4);
}
&.root {
font-weight: 600;
margin-bottom: 4px;
}
&.empty {
opacity: 0.5;
cursor: default;
}
}
.folder-expand {
width: 14px;
font-size: 10px;
text-align: center;
flex-shrink: 0;
user-select: none;
opacity: 0.6;
}
.folder-icon {
flex-shrink: 0;
}
.folder-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-empty-text {
font-size: 11px;
opacity: 0.5;
font-style: italic;
}
.folder-empty {
text-align: center;
padding: 16px;
opacity: 0.5;
}
.folder-selected {
margin-top: 8px;
padding: 6px 8px;
background: rgba(64, 158, 255, 0.08);
border-radius: 4px;
font-size: 12px;
display: flex;
gap: 4px;
align-items: center;
}
.folder-selected-label {
opacity: 0.6;
flex-shrink: 0;
}
.folder-selected-path {
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>