From 58dc257bb527fd5a26dbb76784d8cb7a0a7e3d42 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 18 Jul 2026 00:19:10 +0800 Subject: [PATCH 1/2] fix(browser): register bridge conn before welcome to deflake token auth test HandleWS wrote the welcome frame before registering b.conn, so a client that checks Connected() right after welcome could observe 'not connected' under CI scheduling. Register before the welcome write (rolling back on failure) and route the welcome through writeJSON so it stays serialized with command writes now that the conn is visible to Backend() callers earlier. Also make keepAlivePing/keepAliveWait atomic: tests rewrite these package vars while leftover keepAlive goroutines from prior tests still read them (-race -count=100 flagged it once the first race was fixed). Verified: go test -race -count=100 ./internal/browser/ (86s clean). --- internal/browser/bridge.go | 39 ++++++++++++++++++++++++++------- internal/browser/bridge_test.go | 11 +++++++--- 2 files changed, 39 insertions(+), 11 deletions(-) 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() From 3922db2b1c91ea672e5a0b14ce0a46936562b468 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 18 Jul 2026 00:19:30 +0800 Subject: [PATCH 2/2] fix(web): per-session message queue, restore last conversation, token usage i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue: queued messages lived in one global array wiped by clearChat on every session switch, and agent_done events for non-viewed sessions were dropped, so background queues never drained. Queue is now stashed per session id, sendMessage accepts {sessionId, background}, agent_done carries task_id end-to-end so the bridge drains the queue of the session that actually finished (even while viewing another), and deleting a session discards its queue. Restore: the server never persisted the active conversation, so a restart always landed on a fresh welcome session. The web server now records the last foreground session per project path in ~/.jcode/sessions/last_session.json and reports it via /api/health when the current engine is a post-restart throwaway, letting clients resume the previous conversation through the normal loadSession flow. i18n: ContextCapacityPopup hardcoded all its English strings despite contextCapacity.* keys existing in all 5 locales — wire it to t() and add the missing input/output/cached/reasoning keys; replace hardcoded ' tokens' in usage tooltips with a new common.tokens key. Freshness: loadSession now re-fetches /api/status afterwards so the token snapshot (and provider/model/mode/running) reflect the switched session instead of staying cleared until the next LLM call. --- internal/session/lastsession.go | 100 ++++++++++++++++++++++++++ internal/session/lastsession_test.go | 79 ++++++++++++++++++++ internal/web/engine.go | 5 ++ internal/web/server.go | 16 ++++- web/src/app/runtime.ts | 18 +++-- web/src/app/store.ts | 62 +++++++++++----- web/src/app/wsBridge.ts | 23 ++++-- web/src/components/ChatInput.tsx | 27 +++---- web/src/components/SettingsDialog.tsx | 4 +- web/src/components/Sidebar.tsx | 1 + web/src/i18n/locales/en.ts | 5 ++ web/src/i18n/locales/ja.ts | 5 ++ web/src/i18n/locales/ko.ts | 5 ++ web/src/i18n/locales/zh-Hans.ts | 5 ++ web/src/i18n/locales/zh-Hant.ts | 5 ++ web/src/lib/ws.ts | 19 ++--- 16 files changed, 328 insertions(+), 51 deletions(-) create mode 100644 internal/session/lastsession.go create mode 100644 internal/session/lastsession_test.go 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) }