diff --git a/internal/browser/bridge.go b/internal/browser/bridge.go index bd7bb79a..78f9efc0 100644 --- a/internal/browser/bridge.go +++ b/internal/browser/bridge.go @@ -87,8 +87,9 @@ func (b *Bridge) HandleWS(w http.ResponseWriter, r *http.Request) { } token := hello.Token - _ = conn.WriteJSON(map[string]any{"type": "welcome", "token": token}) - + // Register before writing the welcome so that "welcome received" implies + // Connected() == true for the peer; otherwise a client that acts on the + // welcome immediately could observe the bridge as still offline. bc := newBridgeConn(conn) b.mu.Lock() if b.conn != nil { @@ -97,6 +98,18 @@ func (b *Bridge) HandleWS(w http.ResponseWriter, r *http.Request) { b.conn = bc b.mu.Unlock() + // Write the welcome through bc so it serializes with command traffic: + // registration above already made the conn usable by Backend() callers. + if err := bc.writeJSON(map[string]any{"type": "welcome", "token": token}); err != nil { + b.mu.Lock() + if b.conn == bc { + b.conn = nil + } + b.mu.Unlock() + _ = conn.Close() + return + } + config.Logger().Printf("[browser] extension connected") go bc.keepAlive() bc.readLoop() @@ -143,12 +156,22 @@ type bridgeEnvelope struct { // and the popup flaps to "Reconnecting…". keepAliveWait is the read side: if no // frame (pong, alarm ping, or command reply) arrives within two ping periods, // treat the extension as dead and tear the socket down. -// vars, not consts, so tests can shrink them. +// vars, not consts, so tests can shrink them. Held as atomic nanoseconds: +// keepAlive/readLoop goroutines outlive the test that spawned their conn, so +// plain variables would race with a later test re-tuning the values. var ( - keepAlivePing = 15 * time.Second - keepAliveWait = 40 * time.Second + keepAlivePing = int64(15 * time.Second) + keepAliveWait = int64(40 * time.Second) ) +func keepAlivePingDuration() time.Duration { + return time.Duration(atomic.LoadInt64(&keepAlivePing)) +} + +func keepAliveWaitDuration() time.Duration { + return time.Duration(atomic.LoadInt64(&keepAliveWait)) +} + type bridgeConn struct { ws *websocket.Conn writeMu sync.Mutex @@ -175,7 +198,7 @@ func (c *bridgeConn) writeJSON(v any) error { // and the socket stays up between commands. It exits when the read loop closes // the conn. func (c *bridgeConn) keepAlive() { - t := time.NewTicker(keepAlivePing) + t := time.NewTicker(keepAlivePingDuration()) defer t.Stop() for { select { @@ -201,7 +224,7 @@ func newBridgeConn(ws *websocket.Conn) *bridgeConn { } func (c *bridgeConn) readLoop() { - _ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWait)) + _ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWaitDuration())) for { var env bridgeEnvelope if err := c.ws.ReadJSON(&env); err != nil { @@ -216,7 +239,7 @@ func (c *bridgeConn) readLoop() { return } // Any inbound frame proves the extension is alive; extend the window. - _ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWait)) + _ = c.ws.SetReadDeadline(time.Now().Add(keepAliveWaitDuration())) switch env.Type { case "ping", "pong": // Keepalive traffic (the extension's own alarm ping, or a pong to diff --git a/internal/browser/bridge_test.go b/internal/browser/bridge_test.go index 8424700c..bbc607cd 100644 --- a/internal/browser/bridge_test.go +++ b/internal/browser/bridge_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -149,9 +150,13 @@ func TestBridgeCDPForwarding(t *testing.T) { // ping, no command traffic) must still receive server pings; and if it never // answers, the read watchdog must eventually drop it. func TestBridgeKeepAlivePing(t *testing.T) { - oldPing, oldWait := keepAlivePing, keepAliveWait - keepAlivePing, keepAliveWait = 20*time.Millisecond, 120*time.Millisecond - t.Cleanup(func() { keepAlivePing, keepAliveWait = oldPing, oldWait }) + oldPing, oldWait := keepAlivePingDuration(), keepAliveWaitDuration() + atomic.StoreInt64(&keepAlivePing, int64(20*time.Millisecond)) + atomic.StoreInt64(&keepAliveWait, int64(120*time.Millisecond)) + t.Cleanup(func() { + atomic.StoreInt64(&keepAlivePing, int64(oldPing)) + atomic.StoreInt64(&keepAliveWait, int64(oldWait)) + }) b, wsURL := bridgeServer(t) token := b.IssueToken() diff --git a/internal/session/lastsession.go b/internal/session/lastsession.go new file mode 100644 index 00000000..486ab5ff --- /dev/null +++ b/internal/session/lastsession.go @@ -0,0 +1,100 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/cnjack/jcode/internal/config" +) + +// lastSessionFile is the on-disk structure of last_session.json: the most +// recently foregrounded session per project, so a web/desktop client can +// return to the conversation that was open before a restart. +type lastSessionFile struct { + Projects map[string]string `json:"projects"` // project path → session uuid +} + +func lastSessionPath() (string, error) { + dir, err := config.SessionsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "last_session.json"), nil +} + +// SaveLastSession records id as the last foregrounded session for project. +// Best-effort: persistence must never break session switching, and callers +// run outside any engine lock (file I/O). +func SaveLastSession(project, id string) { + if project == "" || id == "" || ValidateSessionID(id) != nil { + return + } + indexMu.Lock() + defer indexMu.Unlock() + + p, err := lastSessionPath() + if err != nil { + return + } + var f lastSessionFile + if data, readErr := os.ReadFile(p); readErr == nil { + _ = json.Unmarshal(data, &f) // corrupt file → start fresh + } + if f.Projects == nil { + f.Projects = map[string]string{} + } + if f.Projects[project] == id { + return + } + f.Projects[project] = id + + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + return + } + data, err := json.Marshal(&f) + if err != nil { + return + } + // tmp + rename (same pattern as the session index) so a crash mid-write + // never leaves a truncated file. + tmp := p + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return + } + _ = os.Rename(tmp, p) +} + +// LoadLastSession returns the last foregrounded session uuid for project, or +// "" when none is recorded — or when the recorded session no longer exists on +// disk (deleted, or a "new chat" that was never written), so callers fall +// back to a fresh session instead of resurrecting a stale id. +func LoadLastSession(project string) string { + if project == "" { + return "" + } + p, err := lastSessionPath() + if err != nil { + return "" + } + data, err := os.ReadFile(p) + if err != nil { + return "" + } + var f lastSessionFile + if err := json.Unmarshal(data, &f); err != nil { + return "" + } + id := f.Projects[project] + if id == "" || ValidateSessionID(id) != nil { + return "" + } + dir, err := config.SessionsDir() + if err != nil { + return "" + } + if _, err := os.Stat(filepath.Join(dir, id+".json")); err != nil { + return "" + } + return id +} diff --git a/internal/session/lastsession_test.go b/internal/session/lastsession_test.go new file mode 100644 index 00000000..eea1c254 --- /dev/null +++ b/internal/session/lastsession_test.go @@ -0,0 +1,79 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cnjack/jcode/internal/config" +) + +// TestLastSessionRoundTrip covers save → load keyed per project. +func TestLastSessionRoundTrip(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + // Nothing recorded yet → empty. + if got := LoadLastSession("/proj/a"); got != "" { + t.Fatalf("expected empty before any save, got %q", got) + } + + // The loader only accepts sessions whose JSONL exists (a "new chat" that + // was never written must not resurrect), so materialize the session files. + dir, err := config.SessionsDir() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + for _, id := range []string{"11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"} { + if err := os.WriteFile(filepath.Join(dir, id+".json"), []byte("{}\n"), 0644); err != nil { + t.Fatal(err) + } + } + + SaveLastSession("/proj/a", "11111111-1111-1111-1111-111111111111") + SaveLastSession("/proj/b", "22222222-2222-2222-2222-222222222222") + + if got := LoadLastSession("/proj/a"); got != "11111111-1111-1111-1111-111111111111" { + t.Fatalf("proj/a: got %q", got) + } + if got := LoadLastSession("/proj/b"); got != "22222222-2222-2222-2222-222222222222" { + t.Fatalf("proj/b: got %q", got) + } + if got := LoadLastSession("/proj/never-saved"); got != "" { + t.Fatalf("unknown project: expected empty, got %q", got) + } + + // Overwrite moves the project's pointer. + SaveLastSession("/proj/a", "22222222-2222-2222-2222-222222222222") + if got := LoadLastSession("/proj/a"); got != "22222222-2222-2222-2222-222222222222" { + t.Fatalf("proj/a after overwrite: got %q", got) + } +} + +// TestLastSessionSkipsStaleIDs: a recorded id whose session file disappeared +// (deleted conversation, or an empty chat that never hit disk) loads as "". +func TestLastSessionSkipsStaleIDs(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + SaveLastSession("/proj/a", "33333333-3333-3333-3333-333333333333") // file never created + if got := LoadLastSession("/proj/a"); got != "" { + t.Fatalf("stale id: expected empty, got %q", got) + } +} + +// TestLastSessionRejectsBadInput: empty/unsafe values are no-ops, not errors. +func TestLastSessionRejectsBadInput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + SaveLastSession("", "11111111-1111-1111-1111-111111111111") + SaveLastSession("/proj/a", "") + SaveLastSession("/proj/a", "../escape") + if got := LoadLastSession("/proj/a"); got != "" { + t.Fatalf("expected empty after bad saves, got %q", got) + } + if got := LoadLastSession(""); got != "" { + t.Fatalf("empty project: expected empty, got %q", got) + } +} diff --git a/internal/web/engine.go b/internal/web/engine.go index 87b55f12..bb445b8a 100644 --- a/internal/web/engine.go +++ b/internal/web/engine.go @@ -455,6 +455,11 @@ func (s *Server) setActiveEngine(eng *Engine) { s.deleteEngine(prev.taskID) } } + // Remember the foregrounded session per project (keyed by the engine's own + // pwd, so remote workspaces never clobber the local entry) — health reports + // it after a restart so clients return to their last conversation. Runs + // outside s.mu: this is best-effort file I/O. + session.SaveLastSession(eng.pwd, eng.taskID) } // deleteEngine removes a task engine from the map and tears it down (stops its diff --git a/internal/web/server.go b/internal/web/server.go index d76d5d86..f13bf353 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -496,6 +496,20 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { } provider, mdl, modeStr := eng.modelSnapshot() + sessionID := eng.recUUID() + // After a restart the bootstrap engine is a fresh throwaway (no recording, + // not running) whose UUID has no history to restore. Report the project's + // last foregrounded session instead so clients boot straight back into the + // conversation that was open when the app was closed. Once the live engine + // has real state it always reports its own UUID. + eng.emu.Lock() + throwaway := (eng.recorder == nil || !eng.recorder.HasRecording()) && !eng.running.Load() + eng.emu.Unlock() + if throwaway { + if last := session.LoadLastSession(eng.pwd); last != "" { + sessionID = last + } + } writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "version": s.version, @@ -503,7 +517,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { "provider": provider, "model": mdl, "mode": modeStr, - "session_id": eng.recUUID(), + "session_id": sessionID, "running": eng.running.Load(), "image_support": s.currentModelSupportsImage(eng), "auth_required": s.requireAuth, diff --git a/web/src/app/runtime.ts b/web/src/app/runtime.ts index c38e7116..67a8f80b 100644 --- a/web/src/app/runtime.ts +++ b/web/src/app/runtime.ts @@ -21,7 +21,11 @@ import { editMessage, chatActions, } from './store' -import type { ChatImage, AskUserAnswer } from 'jcode-ui-core' +import type { ChatImage, AskUserAnswer, QueuedMessage } from 'jcode-ui-core' + +/** Referentially-stable empty queue for sessions without a stash (keeps the + * runtime selector from re-rendering on every store change). */ +const EMPTY_QUEUE: QueuedMessage[] = [] /** Build the ChatRuntime once. The store singleton is stable, so the runtime * is too. */ @@ -39,13 +43,19 @@ export function useChatRuntime(): ChatRuntime { tokenSnapshot: s.chat.tokenSnapshot, goal: s.chat.goal, todos: s.chat.todos, - queued: s.chat.queued, + // The composer shows only the FOREGROUND session's type-ahead queue; + // other sessions' stashes stay in the store until their agentDone. + queued: s.chat.queuedBySession[s.session.currentSessionId] ?? EMPTY_QUEUE, }), actions: { sendMessage: (text, images) => store.dispatch(sendMessage({ text, images: images as ChatImage[] | undefined })), enqueueMessage: (text, images) => - store.dispatch(chatActions.enqueueMessage({ id: `q_${Date.now()}`, text, images: images as ChatImage[] | undefined })), - removeQueuedMessage: (id) => store.dispatch(chatActions.removeQueued(id)), + store.dispatch(chatActions.enqueueMessage({ + sessionId: store.getState().session.currentSessionId, + message: { id: `q_${Date.now()}`, text, images: images as ChatImage[] | undefined }, + })), + removeQueuedMessage: (id) => + store.dispatch(chatActions.removeQueued({ sessionId: store.getState().session.currentSessionId, id })), stop: () => store.dispatch(stopAgent()), resolveApproval: (id, approved, approveAll) => store.dispatch(resolveApproval({ id, approved, approveAll })), diff --git a/web/src/app/store.ts b/web/src/app/store.ts index 7ea78889..b599da6a 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -36,7 +36,10 @@ interface ChatState { goal: Goal | null goalArmed: boolean todos: TodoItem[] - queued: QueuedMessage[] + /** Type-ahead queues keyed by session id — a message queued while an agent + * runs belongs to THAT conversation and must survive switching away and + * back (previously a single global list wiped by clearChat on switch). */ + queuedBySession: Record slashCommands: SlashCommandInfo[] } @@ -47,7 +50,7 @@ const initialChat: ChatState = { goal: null, goalArmed: false, todos: [], - queued: [], + queuedBySession: {}, slashCommands: [], } @@ -66,7 +69,8 @@ const chatSlice = createSlice({ s.goal = null s.goalArmed = false s.todos = [] - s.queued = [] + // NOTE: queuedBySession is deliberately NOT cleared here — clearChat runs + // on every session switch, and stashed type-ahead queues must survive. streamingText = '' streamingMsgId = '' }, @@ -205,15 +209,27 @@ const chatSlice = createSlice({ setTodos(s, a: { payload: TodoItem[] }) { s.todos = a.payload }, - enqueueMessage(s, a: { payload: QueuedMessage }) { - s.queued.push(a.payload) + enqueueMessage(s, a: { payload: { sessionId: string; message: QueuedMessage } }) { + ;(s.queuedBySession[a.payload.sessionId] ??= []).push(a.payload.message) }, - removeQueued(s, a: { payload: string }) { - s.queued = s.queued.filter((q) => q.id !== a.payload) + removeQueued(s, a: { payload: { sessionId: string; id: string } }) { + const q = s.queuedBySession[a.payload.sessionId] + if (!q) return + const next = q.filter((m) => m.id !== a.payload.id) + if (next.length > 0) s.queuedBySession[a.payload.sessionId] = next + else delete s.queuedBySession[a.payload.sessionId] }, - drainQueue(s) { - // Pops the first queued message — the App thunk resends it on agentDone. - if (s.queued.length > 0) s.queued.shift() + shiftQueued(s, a: { payload: string }) { + // Pops the first queued message of the given session — the WS bridge + // resends it on that session's agentDone. + const q = s.queuedBySession[a.payload] + if (!q) return + q.shift() + if (q.length === 0) delete s.queuedBySession[a.payload] + }, + dropSessionQueue(s, a: { payload: string }) { + // Session was deleted — its stash can never drain again. + delete s.queuedBySession[a.payload] }, agentDone(s, a: { payload: { error?: string; detail?: string } | undefined }) { // Stamp duration on the last assistant message. @@ -634,11 +650,15 @@ export const uiActions = uiSlice.actions // Async thunks — wrap API calls + dispatch the right reducers. export const sendMessage = createAsyncThunk( 'chat/send', - async (payload: { text: string; images?: import('jcode-ui-core').ChatImage[]; mode?: AgentMode }, { dispatch, getState }) => { + async (payload: { text: string; images?: import('jcode-ui-core').ChatImage[]; mode?: AgentMode; sessionId?: string; background?: boolean }, { dispatch, getState }) => { const state = getState() as RootState - const sessionId = state.session.currentSessionId || undefined + // sessionId override targets a specific (possibly background) session — + // used when draining a stashed queue after that session's agentDone. + const sessionId = payload.sessionId ?? (state.session.currentSessionId || undefined) const trimmed = payload.text.trim() - if (state.chat.goalArmed && trimmed && !trimmed.startsWith('/')) { + // Goal flows are foreground-only: the goal API always targets the active + // engine, so a background queue drain sends its text as a plain message. + if (!payload.background && state.chat.goalArmed && trimmed && !trimmed.startsWith('/')) { dispatch(chatActions.setGoalArmed(false)) dispatch(chatActions.addMessage({ role: 'user', content: payload.text })) const goal = await api.setGoal(trimmed, true) @@ -647,7 +667,7 @@ export const sendMessage = createAsyncThunk( return } // /goal slash interception (matches Vue store.sendMessage). - if (trimmed === '/goal' || trimmed.startsWith('/goal ')) { + if (!payload.background && (trimmed === '/goal' || trimmed.startsWith('/goal '))) { const objective = trimmed.slice('/goal'.length).trim() if (objective === '' || objective === 'status') { const goal = await api.goal() @@ -670,8 +690,12 @@ export const sendMessage = createAsyncThunk( dispatch(chatActions.setRunning(true)) return } - dispatch(chatActions.addMessage({ role: 'user', content: payload.text, images: payload.images })) - dispatch(chatActions.setRunning(true)) + // Foreground sends echo into the visible timeline; a background drain must + // not touch the conversation the user is currently viewing. + if (!payload.background) { + 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) { @@ -1048,6 +1072,12 @@ export const loadSession = createAsyncThunk( const resumedId = resp.session_id || uuid const running = !!state.session.tasks.find((t) => t.uuid === resumedId)?.running dispatch(chatActions.setRunning(running)) + + // Rehydrate server-truth state for the resumed session (token snapshot, + // provider/model, mode). clearChat nulled tokenSnapshot, and no + // token_update arrives until the session's next LLM call — without this + // the context ring stays hidden after switching conversations. + await dispatch(loadStatus()) await dispatch(reconcilePendingInteractions()) // Refresh goal + todos (the backend restored them; no WS push on switch). diff --git a/web/src/app/wsBridge.ts b/web/src/app/wsBridge.ts index 23b9ce06..d51c6f9d 100644 --- a/web/src/app/wsBridge.ts +++ b/web/src/app/wsBridge.ts @@ -62,16 +62,27 @@ export function createWSHandlers( ), onTokenUpdate: (d) => dispatch(chatActions.setTokenSnapshot(d)), onAgentDone: (d) => { - dispatch(chatActions.agentDone(d ? { error: d.error, detail: d.detail } : undefined)) + // agent_done arrives for EVERY session (the ws client lets it through the + // foreground filter) so a background session's type-ahead queue can drain + // while the user is viewing another conversation. Foreground-only state + // (timeline, isRunning) is touched only when the done matches the view. + const taskId = d?.task_id + const activeId = getState().session.currentSessionId + const isForeground = !taskId || taskId === activeId + if (isForeground) { + dispatch(chatActions.agentDone(d ? { error: d.error, detail: d.detail } : 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) { + // Drain one queued type-ahead message (terminal-style) from the session + // that just finished — wherever the user is currently looking. + const key = taskId || activeId + const queued = key ? getState().chat.queuedBySession[key] : undefined + if (key && queued && queued.length > 0) { const next = queued[0] - dispatch(chatActions.drainQueue()) - void dispatch(sendMessage({ text: next.text, images: next.images }) as never) + dispatch(chatActions.shiftQueued(key)) + void dispatch(sendMessage({ text: next.text, images: next.images, sessionId: key, background: !isForeground }) as never) } }, onTodoUpdate: () => { diff --git a/web/src/components/ChatInput.tsx b/web/src/components/ChatInput.tsx index df68d4df..d10da2e7 100644 --- a/web/src/components/ChatInput.tsx +++ b/web/src/components/ChatInput.tsx @@ -1280,21 +1280,22 @@ function ContextCapacityPopup({ cached?: number reasoning?: number }) { + const { t } = useTranslation() const context = stats?.context const effectiveLimit = context?.context_limit || limit const rows = context ? [ - { label: 'System prompt', value: context.system_prompt_tokens }, - { label: 'System tools', value: context.system_tools_tokens }, - { label: 'MCP tools', value: context.mcp_tools_tokens }, - { label: 'Skills', value: context.skills_tokens }, - { label: 'Messages', value: context.messages_tokens }, + { label: t('contextCapacity.systemPrompt'), value: context.system_prompt_tokens }, + { label: t('contextCapacity.systemTools'), value: context.system_tools_tokens }, + { label: t('contextCapacity.mcpTools'), value: context.mcp_tools_tokens }, + { label: t('contextCapacity.skills'), value: context.skills_tokens }, + { label: t('contextCapacity.messages'), value: context.messages_tokens }, ] : [ - { label: 'Input', value: prompt }, - { label: 'Output', value: completion }, - { label: 'Cached', value: cached || 0 }, - { label: 'Reasoning', value: reasoning || 0 }, + { label: t('contextCapacity.input'), value: prompt }, + { label: t('contextCapacity.output'), value: completion }, + { label: t('contextCapacity.cached'), value: cached || 0 }, + { label: t('contextCapacity.reasoning'), value: reasoning || 0 }, ] const max = Math.max(1, ...rows.map((r) => r.value)) const percent = effectiveLimit > 0 ? Math.min(100, Math.round((total / effectiveLimit) * 100)) : 0 @@ -1303,9 +1304,9 @@ function ContextCapacityPopup({
-
Context capacity
+
{t('contextCapacity.title')}
- {formatCompact(total)} / {effectiveLimit > 0 ? formatCompact(effectiveLimit) : '-'} tokens + {t('common.tokens', { used: `${formatCompact(total)} / ${effectiveLimit > 0 ? formatCompact(effectiveLimit) : '-'}` })}
= 90 ? 'text-[var(--color-destructive)]' : 'text-[var(--color-primary)]'}`}> @@ -1316,7 +1317,7 @@ function ContextCapacityPopup({
{loading ? ( -
Loading...
+
{t('common.loading')}
) : (
{rows.map((row) => ( @@ -1334,7 +1335,7 @@ function ContextCapacityPopup({ )} {stats?.cache_supported && (
- Cache hit rate: {Math.round(stats.cache_hit_rate * 100)}% + {t('contextCapacity.cacheHitRate')}: {Math.round(stats.cache_hit_rate * 100)}%
)}
diff --git a/web/src/components/SettingsDialog.tsx b/web/src/components/SettingsDialog.tsx index 896e9dd0..61c74467 100644 --- a/web/src/components/SettingsDialog.tsx +++ b/web/src/components/SettingsDialog.tsx @@ -2931,7 +2931,7 @@ function UsageTab() { {heat.map((cell) => ( 0 ? `${cell.date} · ${fmtCompact(cell.tokens)} tokens · ${cell.turns} ${t('settings.usageStats.turnsUnit')}` : `${cell.date} · ${t('settings.usageStats.noActivity')}`} + title={cell.future ? '' : cell.tokens > 0 ? `${cell.date} · ${t('common.tokens', { used: fmtCompact(cell.tokens) })} · ${cell.turns} ${t('settings.usageStats.turnsUnit')}` : `${cell.date} · ${t('settings.usageStats.noActivity')}`} className="h-[11px] w-[11px] rounded-[2px]" style={{ background: cell.future ? 'transparent' : HEAT_FILL[cell.level] }} /> @@ -2947,7 +2947,7 @@ function UsageTab() { {trend.map((d) => (
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index ab9b8b76..a3d02d8e 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -382,6 +382,7 @@ export function Sidebar() { } dispatch(sessionActions.setSessions(sessions.filter((s) => s.uuid !== row.uuid))) dispatch(sessionActions.setTasks(tasks.filter((t) => t.uuid !== row.uuid))) + dispatch(chatActions.dropSessionQueue(row.uuid)) if (wasActive) { dispatch(chatActions.clearChat()) dispatch(sessionActions.setCurrentSession('')) diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 4441b94b..7432dc31 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -22,6 +22,7 @@ export default { edit: 'Edit', retry: 'Retry', loading: 'Loading…', + tokens: '{used} tokens', remove: 'Remove', rename: 'Rename', enable: 'Enable', @@ -701,6 +702,10 @@ export default { mcpTools: 'MCP tools', skills: 'Skills', systemPrompt: 'System prompt', + input: 'Input', + output: 'Output', + cached: 'Cached', + reasoning: 'Reasoning', cacheHitRate: 'Cache hit rate', freeSpace: 'Free space', sessionTotal: 'Conversation total', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 7354d710..4f59c1ac 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -17,6 +17,7 @@ export default { edit: '編集', retry: '再試行', loading: '読み込み中…', + tokens: '{used} トークン', remove: '削除', rename: '名前変更', enable: '有効化', @@ -651,6 +652,10 @@ export default { mcpTools: 'MCP ツール', skills: 'スキル', systemPrompt: 'システムプロンプト', + input: '入力', + output: '出力', + cached: 'キャッシュ', + reasoning: '推論', cacheHitRate: 'キャッシュ率', freeSpace: '空き容量', sessionTotal: '会話の累計', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 73bba41d..8d968c7f 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -17,6 +17,7 @@ export default { edit: '편집', retry: '재시도', loading: '불러오는 중…', + tokens: '{used} 토큰', remove: '제거', rename: '이름 변경', enable: '활성화', @@ -651,6 +652,10 @@ export default { mcpTools: 'MCP 도구', skills: '스킬', systemPrompt: '시스템 프롬프트', + input: '입력', + output: '출력', + cached: '캐시', + reasoning: '추론', cacheHitRate: '캐시 적중률', freeSpace: '여유 공간', sessionTotal: '대화 누적', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index d1f24533..2b92abc4 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -17,6 +17,7 @@ export default { edit: '编辑', retry: '重试', loading: '加载中…', + tokens: '{used} tokens', remove: '移除', rename: '重命名', enable: '启用', @@ -684,6 +685,10 @@ export default { mcpTools: 'MCP 工具', skills: '技能', systemPrompt: '系统提示词', + input: '输入', + output: '输出', + cached: '缓存', + reasoning: '推理', cacheHitRate: '缓存命中率', freeSpace: '剩余空间', sessionTotal: '本会话累计', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 1c7f5a49..799166aa 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -18,6 +18,7 @@ export default { edit: '編輯', retry: '重試', loading: '載入中…', + tokens: '{used} tokens', remove: '移除', rename: '重新命名', enable: '啟用', @@ -652,6 +653,10 @@ export default { mcpTools: 'MCP 工具', skills: '技能', systemPrompt: '系統提示詞', + input: '輸入', + output: '輸出', + cached: '快取', + reasoning: '推理', cacheHitRate: '快取命中率', freeSpace: '剩餘空間', sessionTotal: '本對話累計', diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index e245df9b..88556bd7 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -46,7 +46,7 @@ export interface WSHandlers { presentation?: import('./types').ToolResultPresentation }) => void onTokenUpdate?: (data: import('./types').TokenUpdateData) => void - onAgentDone?: (data: { error?: string; detail?: string }) => void + onAgentDone?: (data: { error?: string; detail?: string; task_id?: string }) => void onTodoUpdate?: () => void onGoalUpdate?: (data: import('jcode-ui-core').Goal | null) => void onApprovalRequest?: (data: import('./types').ApprovalRequestData) => void @@ -72,6 +72,9 @@ interface WSMessage { data?: unknown } +/** Event types whose data payload gets the envelope task_id merged in. */ +const TASK_ID_DATA_TYPES = new Set(['approval_request', 'ask_user_request', 'agent_done']) + export class WSClient { private ws: WebSocket | null = null private retryTimer: ReturnType | null = null @@ -125,17 +128,15 @@ export class WSClient { try { const msg: WSMessage = JSON.parse(event.data) const active = this.handlers.activeTaskId?.() - if (msg.task_id && active && msg.task_id !== active) return + // Events tagged with a different task id are dropped so they don't + // pollute the active view — EXCEPT agent_done, which the bridge needs + // for every session to drain that session's type-ahead queue. + if (msg.task_id && active && msg.task_id !== active && msg.type !== 'agent_done') return const handler = this.handlerFor(msg.type) if (handler) { let data = msg.data - if ( - msg.task_id && - (msg.type === 'approval_request' || msg.type === 'ask_user_request') && - data && - typeof data === 'object' - ) { - data = { ...(data as Record), task_id: msg.task_id } + if (msg.task_id && TASK_ID_DATA_TYPES.has(msg.type)) { + data = { ...((data && typeof data === 'object' ? data : {}) as Record), task_id: msg.task_id } } handler(data) }