From f57f163604a6f1ee0ae12d15657d0d66f3d48fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 28 Apr 2026 22:52:18 +0800 Subject: [PATCH] fix: prevent session inspector context stalls Session inspection was coupling quick status and usage rendering to live context control requests, so a slow get_context_usage path could leave the desktop inspector stuck on a loading state. The desktop panel now loads basic inspection data first, renders a transcript-based context estimate immediately, and only asks for live context details as a background refinement. The translation hook now returns a stable function per locale so effects that depend on translation do not reset and re-fetch after every render. Constraint: Third-party provider sessions may not expose reliable live context capabilities through the control request path. Rejected: Keep waiting on live get_context_usage for the context tab | it recreates the user-visible loading stall. Rejected: Hardcode provider-specific context windows | provider capabilities are not always known from the desktop session record. Confidence: high Scope-risk: moderate Directive: Do not make the inspector first render depend on live CLI control requests without a transcript or cached fallback. Tested: bun test src/server/__tests__/conversations.test.ts -t "structured session inspection|Sonnet 4.6 transcript usage" Tested: cd desktop && bun run test -- --run src/i18n/index.test.tsx Tested: cd desktop && bun run build Tested: git diff --check Tested: agent-browser verified /context renders transcript estimate without Loading context data Not-tested: Provider-specific true context-window discovery for every third-party vendor --- desktop/src/api/sessions.ts | 10 +- .../chat/LocalSlashCommandPanel.tsx | 66 ++++++++- desktop/src/i18n/index.test.tsx | 29 ++++ desktop/src/i18n/index.ts | 8 +- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + src/server/__tests__/conversations.test.ts | 61 ++++++++ src/server/api/sessions.ts | 25 +++- src/server/index.ts | 1 + src/server/services/sessionService.ts | 135 +++++++++++++++++- 10 files changed, 318 insertions(+), 19 deletions(-) create mode 100644 desktop/src/i18n/index.test.tsx diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index a1f9743a..b82a913b 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -135,6 +135,7 @@ export type SessionInspectionResponse = { } usage?: SessionUsageSnapshot context?: SessionContextSnapshot + contextEstimate?: SessionContextSnapshot errors?: Record } @@ -177,9 +178,12 @@ export const sessionsApi = { return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`) }, - getInspection(sessionId: string) { - return api.get(`/api/sessions/${sessionId}/inspection`, { - timeout: 25_000, + getInspection(sessionId: string, options?: { includeContext?: boolean; timeout?: number }) { + const query = options?.includeContext === undefined + ? '' + : `?includeContext=${options.includeContext ? '1' : '0'}` + return api.get(`/api/sessions/${sessionId}/inspection${query}`, { + timeout: options?.timeout ?? (options?.includeContext ? 45_000 : 25_000), }) }, diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index 71fc6408..a56a1d5d 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { skillsApi } from '../../api/skills' import { mcpApi } from '../../api/mcp' import { @@ -494,8 +494,19 @@ function ContextOverview({ context, categories, t }: { context: SessionContextSn ) } -function ContextTab({ context, error, t }: { context?: SessionContextSnapshot; error?: string; t: Translate }) { +function ContextTab({ + context, + error, + loading, + t, +}: { + context?: SessionContextSnapshot + error?: string + loading?: boolean + t: Translate +}) { if (error && !context) return + if (loading && !context) return if (!context) { return } @@ -668,6 +679,9 @@ function SessionInspectorPanel({ const [selectedTab, setSelectedTab] = useState(() => sessionInspectorInitialTab(command)) const [data, setData] = useState(null) const [error, setError] = useState(null) + const [contextLoading, setContextLoading] = useState(false) + const [contextError, setContextError] = useState(null) + const contextRequestSessionRef = useRef(null) useEffect(() => { if (command !== 'status' && command !== 'cost' && command !== 'context') return @@ -682,7 +696,10 @@ function SessionInspectorPanel({ let cancelled = false setData(null) setError(null) - sessionsApi.getInspection(sessionId) + setContextLoading(false) + setContextError(null) + contextRequestSessionRef.current = null + sessionsApi.getInspection(sessionId, { includeContext: false }) .then((response) => { if (!cancelled) setData(assertSessionInspectionResponse(response, t)) }) @@ -692,7 +709,41 @@ function SessionInspectorPanel({ return () => { cancelled = true } - }, [sessionId]) + }, [sessionId, t]) + + useEffect(() => { + if (!sessionId || selectedTab !== 'context' || data === null || data.context) return + if (contextRequestSessionRef.current === sessionId) return + contextRequestSessionRef.current = sessionId + let cancelled = false + setContextLoading(true) + setContextError(null) + sessionsApi.getInspection(sessionId, { includeContext: true, timeout: 45_000 }) + .then((response) => { + if (cancelled) return + const inspected = assertSessionInspectionResponse(response, t) + setData((current) => current + ? { + ...current, + context: inspected.context, + errors: { + ...(current.errors ?? {}), + ...(inspected.errors ?? {}), + }, + } + : inspected) + setContextError(inspected.errors?.context ?? null) + }) + .catch((err) => { + if (!cancelled) setContextError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setContextLoading(false) + }) + return () => { + cancelled = true + } + }, [data, selectedTab, sessionId, t]) const tabs: Array<{ id: SessionInspectorTab; label: string }> = [ { id: 'status', label: t('slash.inspector.tab.status') }, @@ -709,7 +760,12 @@ function SessionInspectorPanel({ ) : selectedTab === 'usage' ? ( ) : selectedTab === 'context' ? ( - + ) : ( )} diff --git a/desktop/src/i18n/index.test.tsx b/desktop/src/i18n/index.test.tsx new file mode 100644 index 00000000..9247db29 --- /dev/null +++ b/desktop/src/i18n/index.test.tsx @@ -0,0 +1,29 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useSettingsStore } from '../stores/settingsStore' +import { useTranslation } from '.' + +describe('useTranslation', () => { + afterEach(() => { + act(() => { + useSettingsStore.getState().setLocale('zh') + }) + }) + + it('keeps the translation function stable until the locale changes', () => { + act(() => { + useSettingsStore.getState().setLocale('zh') + }) + + const { result, rerender } = renderHook(() => useTranslation()) + const initial = result.current + + rerender() + expect(result.current).toBe(initial) + + act(() => { + useSettingsStore.getState().setLocale('en') + }) + expect(result.current).not.toBe(initial) + }) +}) diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 5b1466fb..f7964c1f 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import { useSettingsStore } from '../stores/settingsStore' import { en, type TranslationKey } from './locales/en' import { zh } from './locales/zh' @@ -38,8 +39,11 @@ export function translate( */ export function useTranslation() { const locale = useSettingsStore((s) => s.locale) - return (key: TranslationKey, params?: Record) => - translate(locale, key, params) + return useCallback( + (key: TranslationKey, params?: Record) => + translate(locale, key, params), + [locale], + ) } /** diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 6d7ca233..ca59f1e5 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -572,6 +572,7 @@ export const en = { 'slash.inspector.usage.tokens': 'Tokens', 'slash.inspector.usage.noModelTitle': 'No model usage', 'slash.inspector.usage.noModelBody': 'Token usage will appear here after a model response is recorded.', + 'slash.inspector.context.loading': 'Loading context data', 'slash.inspector.context.emptyTitle': 'No context data yet', 'slash.inspector.context.emptyBody': 'Context usage requires an active CLI session.', 'slash.inspector.context.windowUsage': 'Context window usage', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7aaeebe4..8350dfbb 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -574,6 +574,7 @@ export const zh: Record = { 'slash.inspector.usage.tokens': 'Tokens', 'slash.inspector.usage.noModelTitle': '暂无模型用量', 'slash.inspector.usage.noModelBody': '记录到模型回复后,这里会显示 token 用量。', + 'slash.inspector.context.loading': '正在加载上下文数据', 'slash.inspector.context.emptyTitle': '暂无上下文数据', 'slash.inspector.context.emptyBody': '上下文用量需要活跃的 CLI 会话。', 'slash.inspector.context.windowUsage': '上下文窗口用量', diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 1b9031cd..7c428020 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -278,6 +278,61 @@ describe('ConversationService', () => { await fs.rm(workDir, { recursive: true, force: true }) } }) + + it('should reconstruct Sonnet 4.6 transcript usage before CLI config is initialized', async () => { + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousNodeEnv = process.env.NODE_ENV + const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-sonnet-')) + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-sonnet-')) + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir + process.env.NODE_ENV = 'development' + + try { + const svc = new SessionService() + const { sessionId } = await svc.createSession(workDir) + const found = await svc.findSessionFile(sessionId) + expect(found).not.toBeNull() + + await fs.appendFile(found!.filePath, JSON.stringify({ + type: 'assistant', + uuid: crypto.randomUUID(), + timestamp: '2026-04-27T12:00:00.000Z', + cwd: workDir, + version: '999.0.0-test', + message: { + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'hello' }], + usage: { + input_tokens: 100, + output_tokens: 20, + }, + }, + }) + '\n') + + const usage = await svc.getTranscriptUsage(sessionId) + const contextEstimate = await svc.getTranscriptContextEstimate(sessionId) + + expect(usage?.models[0]?.model).toBe('claude-sonnet-4-6') + expect(usage?.models[0]?.contextWindow).toBe(200_000) + expect(contextEstimate?.model).toBe('claude-sonnet-4-6') + expect(contextEstimate?.totalTokens).toBe(100) + expect(contextEstimate?.rawMaxTokens).toBe(200_000) + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir + } + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = previousNodeEnv + } + await fs.rm(tmpConfigDir, { recursive: true, force: true }) + await fs.rm(workDir, { recursive: true, force: true }) + } + }) }) // ============================================================================ @@ -636,6 +691,12 @@ describe('WebSocket Chat Integration', () => { expect(body.usage.source).toBe('current_process') expect(body.context.model).toBe('mock-opus') expect(body.status.mcpServers).toEqual([{ name: 'mock', status: 'connected' }]) + + const basicRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=0`) + expect(basicRes.status).toBe(200) + const basicBody = await basicRes.json() as any + expect(basicBody.usage.source).toBe('current_process') + expect(basicBody.context).toBeUndefined() }) it('should complete the client turn when the CLI exits after startup', async () => { diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index 6ea38921..d7dae359 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -106,7 +106,7 @@ export async function handleSessionsApi( { status: 405 } ) } - return await getSessionInspection(sessionId) + return await getSessionInspection(sessionId, url) } // Route to conversations handler if sub-resource is 'chat' @@ -217,7 +217,8 @@ async function getSessionSlashCommands(sessionId: string): Promise { return Response.json({ commands: slashCommands }) } -async function getSessionInspection(sessionId: string): Promise { +async function getSessionInspection(sessionId: string, url: URL): Promise { + const includeContext = url.searchParams.get('includeContext') !== '0' const workDir = conversationService.getSessionWorkDir(sessionId) || await sessionService.getSessionWorkDir(sessionId) @@ -264,6 +265,10 @@ async function getSessionInspection(sessionId: string): Promise { errors: {}, } const transcriptUsage = await sessionService.getTranscriptUsage(sessionId) + const transcriptContextEstimate = await sessionService.getTranscriptContextEstimate(sessionId) + if (transcriptContextEstimate) { + response.contextEstimate = transcriptContextEstimate + } if (!active) { if (transcriptUsage) { @@ -271,15 +276,18 @@ async function getSessionInspection(sessionId: string): Promise { } response.errors = { ...(transcriptUsage ? {} : { usage: 'CLI session is not running' }), - context: 'CLI session is not running', + ...(includeContext ? { context: 'CLI session is not running' } : {}), } return Response.json(response) } + const basicControlTimeoutMs = includeContext ? 10_000 : 4_000 const [usageResult, contextResult, mcpResult] = await Promise.allSettled([ - conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }), - conversationService.requestControl(sessionId, { subtype: 'get_context_usage' }, 20_000), - conversationService.requestControl(sessionId, { subtype: 'mcp_status' }), + conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs), + includeContext + ? conversationService.requestControl(sessionId, { subtype: 'get_context_usage' }, 20_000) + : Promise.resolve(null), + conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs), ]) const errors: Record = {} @@ -296,7 +304,10 @@ async function getSessionInspection(sessionId: string): Promise { } } - if (contextResult.status === 'fulfilled') { + if (!includeContext) { + // Context can be expensive on large live sessions. The desktop UI loads it + // separately when the context tab is actually selected. + } else if (contextResult.status === 'fulfilled' && contextResult.value) { response.context = contextResult.value } else { errors.context = contextResult.reason instanceof Error ? contextResult.reason.message : String(contextResult.reason) diff --git a/src/server/index.ts b/src/server/index.ts index 784ccde9..5c53c0da 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -66,6 +66,7 @@ export function startServer(port = PORT, host = HOST) { const server = Bun.serve({ port, hostname: host, + idleTimeout: 60, async fetch(req, server) { const url = new URL(req.url) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 1220a0af..87cc0950 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -12,7 +12,11 @@ import { ApiError } from '../middleware/errorHandler.js' import { sanitizePath as sanitizePortablePath } from '../../utils/sessionStoragePortable.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' import { calculateUSDCost, MODEL_COSTS } from '../../utils/modelCost.js' -import { getContextWindowForModel, getModelMaxOutputTokens } from '../../utils/context.js' +import { + MODEL_CONTEXT_WINDOW_DEFAULT, + getContextWindowForModel, + getModelMaxOutputTokens, +} from '../../utils/context.js' import { getCanonicalName } from '../../utils/model/model.js' // ============================================================================ @@ -93,6 +97,37 @@ export type TranscriptMetadataSnapshot = { version?: string } +export type TranscriptContextEstimate = { + categories: Array<{ + name: string + tokens: number + color: string + isDeferred?: boolean + }> + totalTokens: number + maxTokens: number + rawMaxTokens: number + percentage: number + gridRows: Array> + model: string + memoryFiles: Array<{ path: string; type: string; tokens: number }> + mcpTools: Array<{ name: string; serverName: string; tokens: number; isLoaded?: boolean }> + agents: Array<{ agentType: string; source: string; tokens: number }> + apiUsage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number + } +} + /** Raw entry parsed from a single JSONL line */ type RawEntry = { type?: string @@ -704,6 +739,20 @@ export class SessionService { return `$${cost > 0.5 ? (Math.round(cost * 100) / 100).toFixed(2) : cost.toFixed(4)}` } + private getTranscriptContextWindow(model: string): number { + try { + return getContextWindowForModel(model) + } catch (err) { + if ( + err instanceof Error && + err.message.includes('Config accessed before allowed') + ) { + return MODEL_CONTEXT_WINDOW_DEFAULT + } + throw err + } + } + async getTranscriptMetadata(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null @@ -728,6 +777,88 @@ export class SessionService { return metadata } + async getTranscriptContextEstimate(sessionId: string): Promise { + const found = await this.findSessionFile(sessionId) + if (!found) return null + + const entries = await this.readJsonlFile(found.filePath) + let latest: { + model: string + inputTokens: number + outputTokens: number + cacheReadInputTokens: number + cacheCreationInputTokens: number + } | null = null + + for (const entry of entries) { + const usage = entry.message?.usage + const model = entry.message?.model + if (!usage || typeof model !== 'string') continue + + const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0 + const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0 + const cacheReadInputTokens = typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0 + const cacheCreationInputTokens = typeof usage.cache_creation_input_tokens === 'number' ? usage.cache_creation_input_tokens : 0 + const promptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens + if (promptTokens === 0 && outputTokens === 0) continue + + latest = { + model, + inputTokens, + outputTokens, + cacheReadInputTokens, + cacheCreationInputTokens, + } + } + + if (!latest) return null + + const rawMaxTokens = this.getTranscriptContextWindow(latest.model) + const totalTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens + const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0 + const categories: TranscriptContextEstimate['categories'] = [ + { name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' }, + { name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' }, + { name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' }, + { name: 'Free space', tokens: Math.max(0, rawMaxTokens - totalTokens), color: '#a1a1aa', isDeferred: true }, + ].filter((category) => category.tokens > 0) + + const filledSquares = Math.max(0, Math.min(100, Math.round((totalTokens / Math.max(1, rawMaxTokens)) * 100))) + const gridRows = Array.from({ length: 10 }, (_, row) => + Array.from({ length: 10 }, (_, col) => { + const index = row * 10 + col + const isFilled = index < filledSquares + return { + color: isFilled ? '#8f3217' : '#a1a1aa', + isFilled, + categoryName: isFilled ? 'Input context' : 'Free space', + tokens: Math.round(rawMaxTokens / 100), + percentage: 1, + squareFullness: isFilled ? 1 : 0, + } + }), + ) + + return { + categories, + totalTokens, + maxTokens: rawMaxTokens, + rawMaxTokens, + percentage, + gridRows, + model: latest.model, + memoryFiles: [], + mcpTools: [], + agents: [], + apiUsage: { + input_tokens: latest.inputTokens, + output_tokens: latest.outputTokens, + cache_creation_input_tokens: latest.cacheCreationInputTokens, + cache_read_input_tokens: latest.cacheReadInputTokens, + }, + } + } + async getTranscriptUsage(sessionId: string): Promise { const found = await this.findSessionFile(sessionId) if (!found) return null @@ -794,7 +925,7 @@ export class SessionService { webSearchRequests: 0, costUSD: 0, costDisplay: '$0.0000', - contextWindow: getContextWindowForModel(model), + contextWindow: this.getTranscriptContextWindow(model), maxOutputTokens: getModelMaxOutputTokens(model).default, } models.set(model, modelUsage)