From c154c78856d3a063d830e20dbf7af50120741ceb Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 01:39:34 +0800 Subject: [PATCH 1/2] fix(web): show new sessions in sidebar and stop doubled streaming text Reveal a task in the left sidebar as soon as the first user message is sent (backend only indexes after RecordUser), with a real title instead of a UUID fragment. Fix WSClient so disconnect() does not auto-reconnect under React StrictMode, which left a ghost socket that doubled every agent_text delta. --- web/src/app/store.ts | 118 +++++++++++++++++++++++++- web/src/app/wsBridge.ts | 5 ++ web/src/components/CommandPalette.tsx | 2 +- web/src/components/Sidebar.tsx | 68 ++++++++++++--- web/src/i18n/locales/en.ts | 1 + web/src/i18n/locales/ja.ts | 1 + web/src/i18n/locales/ko.ts | 1 + web/src/i18n/locales/zh-Hans.ts | 1 + web/src/i18n/locales/zh-Hant.ts | 1 + web/src/lib/ws.ts | 75 ++++++++++++---- 10 files changed, 243 insertions(+), 30 deletions(-) diff --git a/web/src/app/store.ts b/web/src/app/store.ts index a4b7add0..82d69fe8 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -297,10 +297,24 @@ const sessionSlice = createSlice({ initialState: initialSession, reducers: { setSessions(s, a: { payload: SessionItem[] }) { - s.sessions = a.payload + // Same lazy-index race as setTasks: keep the open session if the server + // hasn't written it to the index yet. + const next = a.payload + const seen = new Set(next.map((x) => x.uuid)) + const localOnly = s.sessions.filter( + (x) => !seen.has(x.uuid) && x.uuid === s.currentSessionId, + ) + s.sessions = localOnly.length ? [...localOnly, ...next] : next }, setTasks(s, a: { payload: TaskItem[] }) { - s.tasks = a.payload + // Preserve a just-created local task that isn't in the server index yet + // (session files are created lazily on the first user message). + const next = a.payload + const seen = new Set(next.map((t) => t.uuid)) + const localOnly = s.tasks.filter( + (t) => !seen.has(t.uuid) && t.uuid === s.currentSessionId, + ) + s.tasks = localOnly.length ? [...localOnly, ...next] : next }, setCurrentSession(s, a: { payload: string }) { s.currentSessionId = a.payload @@ -315,6 +329,31 @@ const sessionSlice = createSlice({ const t = s.tasks.find((x) => x.uuid === a.payload.taskId) if (t) t.running = a.payload.running }, + /** Insert or merge a task so the sidebar shows it immediately. */ + upsertTask(s, a: { payload: TaskItem }) { + const i = s.tasks.findIndex((t) => t.uuid === a.payload.uuid) + if (i >= 0) { + s.tasks[i] = { ...s.tasks[i], ...a.payload } + } else { + s.tasks.unshift(a.payload) + } + }, + /** Patch fields on an existing task (no-op if missing). */ + patchTask(s, a: { payload: { uuid: string } & Partial }) { + const t = s.tasks.find((x) => x.uuid === a.payload.uuid) + if (!t) return + const { uuid: _uuid, ...rest } = a.payload + Object.assign(t, rest) + }, + /** Insert or merge a session for the active-project fallback list. */ + upsertSession(s, a: { payload: SessionItem }) { + const i = s.sessions.findIndex((x) => x.uuid === a.payload.uuid) + if (i >= 0) { + s.sessions[i] = { ...s.sessions[i], ...a.payload } + } else { + s.sessions.unshift(a.payload) + } + }, }, }) @@ -519,11 +558,84 @@ export const sendMessage = createAsyncThunk( } dispatch(chatActions.addMessage({ role: 'user', content: payload.text, images: payload.images })) dispatch(chatActions.setRunning(true)) + // First user turn materializes the session on disk — surface it in the + // sidebar immediately (title + running) before the chat HTTP round-trip. + if (sessionId) { + dispatch(revealSessionInSidebar({ + uuid: sessionId, + title: sessionTitleFromMessage(payload.text), + running: true, + })) + } const resp = await api.chat(payload.text, payload.mode, sessionId, payload.images) - if (!state.session.currentSessionId) dispatch(sessionActions.setCurrentSession(resp.session_id)) + const sid = resp.session_id || sessionId + if (sid) { + if (!state.session.currentSessionId) dispatch(sessionActions.setCurrentSession(sid)) + dispatch(revealSessionInSidebar({ + uuid: sid, + title: sessionTitleFromMessage(payload.text), + running: true, + })) + // Reconcile with the server index (now written by RecordUser). + void dispatch(loadSessions()) + void dispatch(loadTasks()) + } }, ) +/** Match backend generateTitle so the sidebar title doesn't flicker after refresh. */ +function sessionTitleFromMessage(content: string): string { + const first = content.split(/\r?\n/, 1)[0]?.trim() ?? '' + if (!first) return 'New session' + const chars = Array.from(first) + return chars.length > 80 ? chars.slice(0, 80).join('') + '…' : first +} + +/** + * Ensure a session/task appears in the left sidebar immediately. + * Backend only indexes a session after the first recorded message; empty + * "new chat" UUIDs are otherwise invisible until the next full reload. + */ +export function revealSessionInSidebar(opts: { + uuid: string + title?: string + running?: boolean + project?: string + provider?: string + model?: string +}) { + return (dispatch: AppDispatch, getState: () => RootState) => { + if (!opts.uuid) return + const state = getState() + const now = new Date().toISOString() + const project = opts.project || state.session.projectPath || '' + const existing = state.session.tasks.find((t) => t.uuid === opts.uuid) + // First non-empty title wins (matches backend generateTitle on first user msg). + const title = existing?.title || opts.title || '' + dispatch(sessionActions.upsertTask({ + uuid: opts.uuid, + project: existing?.project || project, + created_at: existing?.created_at || now, + updated_at: now, + provider: opts.provider || existing?.provider || state.model.providerName || '', + model: opts.model || existing?.model || state.model.modelName || '', + title, + pinned: existing?.pinned ?? false, + archived: existing?.archived ?? false, + unread: existing?.unread ?? false, + status: existing?.status, + running: opts.running ?? existing?.running ?? false, + })) + dispatch(sessionActions.upsertSession({ + uuid: opts.uuid, + created_at: existing?.created_at || now, + provider: opts.provider || existing?.provider || state.model.providerName || '', + model: opts.model || existing?.model || state.model.modelName || '', + title: title || undefined, + })) + } +} + export const stopAgent = createAsyncThunk('chat/stop', async (_, { getState }) => { const state = getState() as RootState await api.stop(state.session.currentSessionId || undefined) diff --git a/web/src/app/wsBridge.ts b/web/src/app/wsBridge.ts index f469de2e..fad3148e 100644 --- a/web/src/app/wsBridge.ts +++ b/web/src/app/wsBridge.ts @@ -14,6 +14,8 @@ import { sessionActions, modelActions, sendMessage, + loadTasks, + loadSessions, } from './store' import { api } from '../lib/api' import type { Approval, Goal } from 'jcode-ui-core' @@ -52,6 +54,9 @@ export function createWSHandlers( onTokenUpdate: (d) => dispatch(chatActions.setTokenSnapshot(d)), onAgentDone: (d) => { dispatch(chatActions.agentDone(d ? { error: d.error } : undefined)) + // Refresh sidebar metadata (title / updated_at / running) after a turn. + void dispatch(loadTasks() as never) + void dispatch(loadSessions() as never) // Drain one queued type-ahead message (terminal-style), if any. const queued = getState().chat.queued if (queued.length > 0) { diff --git a/web/src/components/CommandPalette.tsx b/web/src/components/CommandPalette.tsx index 5c84bf67..29186ee0 100644 --- a/web/src/components/CommandPalette.tsx +++ b/web/src/components/CommandPalette.tsx @@ -43,8 +43,8 @@ export function CommandPalette() { dispatch(uiActions.setView('chat')) dispatch(chatActions.clearChat()) const resp = await api.newSession() + // Stay off the sidebar until the first user message (empty UUID rows look broken). dispatch(sessionActions.setCurrentSession(resp.session_id)) - await dispatch(loadWorkspaceState()) } async function openTask(task: TaskItem) { diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index f34fbe1a..cc2b31a9 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -31,7 +31,7 @@ import { } from '@heroicons/react/24/outline' import { useTranslation } from 'react-i18next' import { useAppDispatch, useAppSelector } from '../app/hooks' -import { uiActions, sessionActions, chatActions, loadSession, loadTasks, loadWorkspaceState } from '../app/store' +import { uiActions, sessionActions, chatActions, loadSession, loadWorkspaceState } from '../app/store' import { api } from '../lib/api' import type { TaskItem } from '../lib/types' import { ThemeToggle } from './ThemeToggle' @@ -102,6 +102,12 @@ export function Sidebar() { const [ctx, setCtx] = useState(null) const ctxRef = useRef(null) + // Track rows that just appeared so we can play a one-shot enter animation + // (avoids a hard pop-in when a new session is revealed optimistically). + const knownUuids = useRef>(new Set()) + const [entering, setEntering] = useState>(() => new Set()) + const seededList = useRef(false) + // A coarse clock so time-based filtering/grouping re-evaluates as the wall // clock advances (Date.now() isn't a reactive dep on its own). const [now, setNow] = useState(() => Date.now()) @@ -152,6 +158,37 @@ export function Sidebar() { return out }, [tasks, sessions, activePath]) + // After the first non-empty paint, animate only newly-added rows (not the + // initial hydrate of the whole list). + useEffect(() => { + const ids = rows.map((r) => r.uuid) + if (!seededList.current) { + if (ids.length === 0) return + knownUuids.current = new Set(ids) + seededList.current = true + return + } + const added: string[] = [] + for (const id of ids) { + if (!knownUuids.current.has(id)) added.push(id) + } + knownUuids.current = new Set(ids) + if (added.length === 0) return + setEntering((prev) => { + const next = new Set(prev) + for (const id of added) next.add(id) + return next + }) + const t = window.setTimeout(() => { + setEntering((prev) => { + const next = new Set(prev) + for (const id of added) next.delete(id) + return next + }) + }, 220) + return () => window.clearTimeout(t) + }, [rows]) + const projects = useMemo(() => { const map = new Map() if (activePath) map.set(activePath, projectName(activePath)) @@ -174,6 +211,10 @@ export function Sidebar() { const filtered = useMemo(() => { return rows.filter((r) => { + // Untitled rows are empty sessions that never recorded a message (or + // optimistic placeholders). Keep them out of the list — welcome stays + // clean until the first user turn materializes a real title. + if (!r.title?.trim() && !r.running) return false // The open conversation always stays visible regardless of filters — // otherwise archiving or a narrowing window would strand it. if (r.uuid === currentSessionId) return true @@ -193,17 +234,19 @@ export function Sidebar() { // ── Sort: running first, then pinned, then the chosen key ── + const untitledLabel = t('sidebar.untitled') + const sorted = useMemo(() => { const arr = [...filtered] arr.sort((a, b) => { if (a.running !== b.running) return a.running ? -1 : 1 if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 - if (filters.sort === 'name') return rowTitle(a).localeCompare(rowTitle(b)) + if (filters.sort === 'name') return rowTitle(a, untitledLabel).localeCompare(rowTitle(b, untitledLabel)) if (filters.sort === 'created') return (b.created_at || '').localeCompare(a.created_at || '') return (b.updated_at || '').localeCompare(a.updated_at || '') }) return arr - }, [filtered, filters.sort]) + }, [filtered, filters.sort, untitledLabel]) // ── Group by project or recency ── @@ -281,10 +324,9 @@ export function Sidebar() { dispatch(uiActions.setView('chat')) try { const resp = await api.newSession() + // Keep the welcome screen empty-session out of the sidebar until the + // first user message (backend only indexes then; a UUID-only row looks broken). dispatch(sessionActions.setCurrentSession(resp.session_id)) - const fresh = await api.sessions() - dispatch(sessionActions.setSessions(fresh)) - dispatch(loadTasks()) } catch { // surfaced via health/gate } @@ -566,7 +608,7 @@ export function Sidebar() { } }} onContextMenu={(e) => openContext(e, row)} - className={`sb-task-row group ${active ? 'active' : ''} ${row.archived ? 'archived' : ''} ${row.running ? 'running' : ''}`} + className={`sb-task-row group ${active ? 'active' : ''} ${row.archived ? 'archived' : ''} ${row.running ? 'running' : ''} ${entering.has(row.uuid) ? 'sb-task-enter' : ''}`} > {row.running ? (