diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 055400b7..fd14d03c 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, it, expect, vi } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { renderToStaticMarkup } from 'react-dom/server' import '@testing-library/jest-dom' import { skillsApi } from '../api/skills' @@ -75,6 +76,7 @@ import { ToolInspection } from '../pages/ToolInspection' // Layout components (chrome is now here, not in pages) import { Sidebar } from '../components/layout/Sidebar' import { UserMessage } from '../components/chat/UserMessage' +import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { useChatStore } from '../stores/chatStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' @@ -136,6 +138,29 @@ describe('Content-only pages render without errors', () => { expect(container.innerHTML).toContain('Ask anything') }) + it('EmptySession shows draft context usage before a session is created', async () => { + render() + + const indicator = await screen.findByLabelText('Context usage not calculated') + expect(indicator).toHaveTextContent('--') + expect(vi.mocked(sessionsApi.getInspection)).not.toHaveBeenCalled() + }) + + it('ContextUsageIndicator does not render a first-paint spinner for draft sessions', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toMatch(/aria-label="(Context usage not calculated|上下文用量待计算)"/) + expect(html).toContain('--') + expect(html).not.toContain('animate-spin') + }) + it('EmptySession plus menu exposes uploads and slash commands before chat starts', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Open composer tools' })) @@ -677,7 +702,8 @@ describe('Content-only pages render without errors', () => { expect(screen.getByText('120,000')).toBeInTheDocument() expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, { includeContext: true, - timeout: 45_000, + contextOnly: true, + timeout: 20_000, }) useTabStore.setState({ tabs: [], activeTabId: null }) @@ -739,6 +765,74 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession treats an empty idle session without a running CLI as a 0% placeholder', async () => { + const SESSION_ID = 'context-empty-idle-session' + vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({ + active: false, + status: { + sessionId: SESSION_ID, + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + model: 'kimi-k2.6', + }, + errors: { + context: 'CLI session is not running', + }, + }) + + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + const indicator = await screen.findByLabelText('Context usage not calculated') + expect(indicator).toHaveTextContent('--') + expect(screen.getAllByText('kimi-k2.6').length).toBeGreaterThan(0) + expect(screen.getByText('Context usage will be calculated after the session starts.')).toBeInTheDocument() + expect(screen.queryByText('CLI session is not running')).not.toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + it('ActiveSession shows context estimate during compaction or reconnect fallback', async () => { const SESSION_ID = 'context-estimate-session' vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({ @@ -825,6 +919,76 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession keeps selected runtime model visible when context is unavailable', async () => { + const SESSION_ID = 'context-unavailable-model-session' + vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({ + active: false, + status: { + sessionId: SESSION_ID, + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + }, + errors: { + context: 'CLI session is not running', + }, + }) + + useTabStore.setState({ tabs: [{ sessionId: SESSION_ID, title: 'Test', type: 'session' as const, status: 'idle' }], activeTabId: SESSION_ID }) + useSessionStore.setState({ + sessions: [{ + id: SESSION_ID, + title: 'Test', + createdAt: '2026-04-10T00:00:00.000Z', + modifiedAt: '2026-04-10T00:00:00.000Z', + messageCount: 1, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useSessionRuntimeStore.getState().setSelection(SESSION_ID, { + providerId: 'volcengine-provider', + modelId: 'kimi-k2.6', + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [{ id: 'm-1', type: 'assistant_text', content: 'ready', timestamp: Date.now() }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + expect(await screen.findByLabelText('Context usage unavailable')).toBeInTheDocument() + expect(screen.getAllByText('kimi-k2.6').length).toBeGreaterThan(0) + expect(screen.queryByText('Unknown model')).not.toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + useSessionRuntimeStore.setState({ selections: {} }) + }) + it('ActiveSession refreshes context usage when the selected runtime model changes', async () => { const SESSION_ID = 'context-runtime-refresh-session' vi.mocked(sessionsApi.getInspection) diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 2ec470b0..8fa5166f 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -274,11 +274,16 @@ export const sessionsApi = { return api.get<{ commands: Array<{ name: string; description: string }> }>(`/api/sessions/${sessionId}/slash-commands`) }, - 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}`, { + getInspection(sessionId: string, options?: { includeContext?: boolean; timeout?: number; contextOnly?: boolean }) { + const query = new URLSearchParams() + if (options?.includeContext !== undefined) { + query.set('includeContext', options.includeContext ? '1' : '0') + } + if (options?.contextOnly) { + query.set('contextOnly', '1') + } + const suffix = query.size > 0 ? `?${query.toString()}` : '' + return api.get(`/api/sessions/${sessionId}/inspection${suffix}`, { timeout: options?.timeout ?? (options?.includeContext ? 45_000 : 25_000), }) }, diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 4e0f29d6..2030c315 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -6,6 +6,7 @@ import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTeamStore } from '../../stores/teamStore' +import { useSettingsStore } from '../../stores/settingsStore' import { formatWorkspaceReferencePrompt, useWorkspaceChatContextStore, @@ -94,9 +95,11 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const runtimeSelection = useSessionRuntimeStore((state) => activeTabId ? state.selections[activeTabId] : undefined, ) + const currentModel = useSettingsStore((state) => state.currentModel) const runtimeSelectionKey = runtimeSelection ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}` : undefined + const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) @@ -826,6 +829,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro chatState={chatState} messageCount={messageCount} runtimeSelectionKey={runtimeSelectionKey} + fallbackModelLabel={runtimeModelLabel} compact={compact} /> )} diff --git a/desktop/src/components/chat/ContextUsageIndicator.tsx b/desktop/src/components/chat/ContextUsageIndicator.tsx index 6b4b391b..bb9e7f7e 100644 --- a/desktop/src/components/chat/ContextUsageIndicator.tsx +++ b/desktop/src/components/chat/ContextUsageIndicator.tsx @@ -4,14 +4,17 @@ import { useTranslation } from '../../i18n' import type { ChatState } from '../../types/chat' type Props = { - sessionId: string + sessionId?: string chatState: ChatState messageCount: number runtimeSelectionKey?: string + fallbackModelLabel?: string + draft?: boolean compact?: boolean } const ACTIVE_REFRESH_MS = 30_000 +const CONTEXT_REQUEST_TIMEOUT_MS = 20_000 function formatNumber(value: number | undefined) { return new Intl.NumberFormat().format(value ?? 0) @@ -38,38 +41,66 @@ function pickUsedContextCategory(context: SessionContextSnapshot) { .slice(0, 4) } +function firstNonEmpty(...values: Array) { + return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim() +} + +function isCliNotRunningError(error: string | null) { + return error?.toLowerCase().includes('cli session is not running') ?? false +} + +function shouldFetchContext(sessionId: string | undefined, draft: boolean) { + return Boolean(sessionId) && !draft +} + export function ContextUsageIndicator({ sessionId, chatState, messageCount, runtimeSelectionKey = '', + fallbackModelLabel, + draft = false, compact = false, }: Props) { const t = useTranslation() const [context, setContext] = useState(null) const [contextSource, setContextSource] = useState<'live' | 'estimate' | null>(null) - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(() => shouldFetchContext(sessionId, draft)) const [error, setError] = useState(null) const [updatedAt, setUpdatedAt] = useState(null) + const [inspectionModel, setInspectionModel] = useState(null) const requestSeq = useRef(0) const contextIdentityRef = useRef('') const refresh = useCallback(async () => { - if (!sessionId) return + if (!sessionId || draft) { + setLoading(false) + return + } + const activeSessionId = sessionId const seq = requestSeq.current + 1 requestSeq.current = seq setLoading(true) setError(null) try { - const inspection = await sessionsApi.getInspection(sessionId, { + const inspection = await sessionsApi.getInspection(activeSessionId, { includeContext: true, - timeout: 45_000, + contextOnly: true, + timeout: CONTEXT_REQUEST_TIMEOUT_MS, }) if (seq !== requestSeq.current) return const nextContext = inspection.context ?? inspection.contextEstimate ?? null const nextSource = inspection.context ? 'live' : inspection.contextEstimate ? 'estimate' : null + const usageModel = inspection.usage?.models.find((model) => firstNonEmpty(model.displayName, model.model)) ?? null setContext(nextContext) setContextSource(nextSource) + setInspectionModel(firstNonEmpty( + inspection.context?.model, + inspection.contextEstimate?.model, + inspection.status?.model, + usageModel?.displayName, + usageModel?.model, + ) ?? null) setError(nextContext ? null : inspection.errors?.context ?? null) setUpdatedAt(Date.now()) } catch (err) { @@ -78,7 +109,7 @@ export function ContextUsageIndicator({ } finally { if (seq === requestSeq.current) setLoading(false) } - }, [sessionId]) + }, [draft, sessionId]) useEffect(() => { const contextIdentity = `${sessionId}:${runtimeSelectionKey}` @@ -89,13 +120,9 @@ export function ContextUsageIndicator({ setContextSource(null) setError(null) setUpdatedAt(null) + setInspectionModel(null) } void refresh() - if (!identityChanged) return - const retryTimer = setTimeout(() => { - void refresh() - }, 2_500) - return () => clearTimeout(retryTimer) }, [messageCount, refresh, runtimeSelectionKey, sessionId]) useEffect(() => { @@ -111,6 +138,10 @@ export function ContextUsageIndicator({ return pickUsedContextCategory(context) }, [context]) + const hasPlaceholderContext = !context && ( + draft || (!loading && messageCount === 0 && (!error || isCliNotRunningError(error))) + ) + const isPendingContext = hasPlaceholderContext && !context const percentage = context ? Math.max(0, Math.min(100, context.percentage)) : 0 const usedTokens = context?.totalTokens ?? 0 const maxTokens = context?.rawMaxTokens ?? 0 @@ -126,8 +157,11 @@ export function ContextUsageIndicator({ : 'var(--color-surface-container-high)', } const displayPercent = context ? formatPercent(percentage) : '--' + const displayModel = firstNonEmpty(context?.model, inspectionModel, fallbackModelLabel) const ariaLabel = context ? t('contextIndicator.ariaLabel', { percent: formatPercent(percentage) }) + : isPendingContext + ? t('contextIndicator.pendingAria') : loading ? t('contextIndicator.loadingAria') : t('contextIndicator.unavailableAria') @@ -144,18 +178,20 @@ export function ContextUsageIndicator({ compact ? 'px-2' : 'px-2.5' }`} > - - + {loading && !context ? ( - progress_activity + ) : ( + className="relative grid h-[18px] w-[18px] place-items-center rounded-full" + style={ringStyle} + > + + + )} @@ -170,7 +206,7 @@ export function ContextUsageIndicator({ {t('contextIndicator.title')}
- {context?.model ?? t('contextIndicator.modelUnknown')} + {displayModel ?? t('contextIndicator.modelUnknown')}
@@ -191,7 +227,7 @@ export function ContextUsageIndicator({
{t('contextIndicator.window')}
-
{formatNumber(maxTokens)}
+
{maxTokens > 0 ? formatNumber(maxTokens) : '--'}
{details.length > 0 && ( @@ -221,9 +257,13 @@ export function ContextUsageIndicator({ )} + ) : isPendingContext ? ( +
+ {t('contextIndicator.pendingDetail')} +
) : (
- {loading ? t('contextIndicator.loading') : error ?? t('contextIndicator.unavailableDetail')} + {loading ? t('contextIndicator.loading') : t('contextIndicator.unavailableDetail')}
)} diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index abae9374..65612bb8 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -717,7 +717,7 @@ function SessionInspectorPanel({ let cancelled = false setContextLoading(true) setContextError(null) - sessionsApi.getInspection(sessionId, { includeContext: true, timeout: 45_000 }) + sessionsApi.getInspection(sessionId, { includeContext: true, contextOnly: true, timeout: 45_000 }) .then((response) => { if (cancelled) return const inspected = assertSessionInspectionResponse(response, t) diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index a1af3279..83f4e6ef 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -28,16 +28,19 @@ export function AppShell() { await initializeDesktopServerUrl() await fetchSettings() - // Restore tabs from localStorage - await useTabStore.getState().restoreTabs() - const { activeTabId: activeId, tabs } = useTabStore.getState() - const activeTab = tabs.find((tab) => tab.sessionId === activeId) - if (activeId && activeTab?.type === 'session') { - useChatStore.getState().connectToSession(activeId) - } if (!cancelled) { setReady(true) } + + void (async () => { + await useTabStore.getState().restoreTabs() + if (cancelled) return + const { activeTabId: activeId, tabs } = useTabStore.getState() + const activeTab = tabs.find((tab) => tab.sessionId === activeId) + if (activeId && activeTab?.type === 'session') { + useChatStore.getState().connectToSession(activeId) + } + })().catch(() => {}) } catch (error) { if (!cancelled) { setStartupError(error instanceof Error ? error.message : String(error)) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index bed1923f..0e3bc38f 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -757,6 +757,7 @@ export const en = { 'slash.inspector.context.noCategoriesTitle': 'No context categories', 'slash.inspector.context.noCategoriesBody': 'Context categories will appear after the CLI reports context analysis.', 'contextIndicator.ariaLabel': 'Context usage {percent}', + 'contextIndicator.pendingAria': 'Context usage not calculated', 'contextIndicator.loadingAria': 'Context usage loading', 'contextIndicator.unavailableAria': 'Context usage unavailable', 'contextIndicator.title': 'Context', @@ -765,6 +766,7 @@ export const en = { 'contextIndicator.free': 'Free', 'contextIndicator.window': 'Window', 'contextIndicator.loading': 'Loading context usage...', + 'contextIndicator.pendingDetail': 'Context usage will be calculated after the session starts.', 'contextIndicator.unavailable': 'n/a', 'contextIndicator.unavailableDetail': 'Context usage is unavailable for this session.', 'contextIndicator.updatedUnknown': 'Not refreshed yet', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 841c851e..8c5a5e2f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -759,6 +759,7 @@ export const zh: Record = { 'slash.inspector.context.noCategoriesTitle': '暂无上下文分类', 'slash.inspector.context.noCategoriesBody': 'CLI 返回上下文分析后,这里会显示分类数据。', 'contextIndicator.ariaLabel': '上下文用量 {percent}', + 'contextIndicator.pendingAria': '上下文用量待计算', 'contextIndicator.loadingAria': '上下文用量加载中', 'contextIndicator.unavailableAria': '上下文用量不可用', 'contextIndicator.title': '上下文', @@ -767,6 +768,7 @@ export const zh: Record = { 'contextIndicator.free': '剩余', 'contextIndicator.window': '窗口', 'contextIndicator.loading': '正在加载上下文用量...', + 'contextIndicator.pendingDetail': '会话启动后计算上下文用量。', 'contextIndicator.unavailable': '不可用', 'contextIndicator.unavailableDetail': '此会话暂时无法获取上下文用量。', 'contextIndicator.updatedUnknown': '尚未刷新', diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 4d197d0c..3655d22f 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -13,6 +13,7 @@ import { DirectoryPicker } from '../components/shared/DirectoryPicker' import { PermissionModeSelector } from '../components/controls/PermissionModeSelector' import { ModelSelector } from '../components/controls/ModelSelector' import { AttachmentGallery } from '../components/chat/AttachmentGallery' +import { ContextUsageIndicator } from '../components/chat/ContextUsageIndicator' import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from '../components/chat/LocalSlashCommandPanel' import { @@ -63,6 +64,12 @@ export function EmptySession() { const connectToSession = useChatStore((state) => state.connectToSession) const setActiveView = useUIStore((state) => state.setActiveView) const addToast = useUIStore((state) => state.addToast) + const currentModel = useSettingsStore((state) => state.currentModel) + const draftRuntimeSelection = useSessionRuntimeStore((state) => state.selections[DRAFT_RUNTIME_SELECTION_KEY]) + const draftRuntimeSelectionKey = draftRuntimeSelection + ? `${draftRuntimeSelection.providerId ?? 'official'}:${draftRuntimeSelection.modelId}` + : undefined + const draftModelLabel = draftRuntimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id useEffect(() => { textareaRef.current?.focus() @@ -667,6 +674,13 @@ export function EmptySession() {
+