mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: avoid misleading context loading on empty desktop sessions
The desktop composer now treats draft and not-yet-running sessions as pending until a real CLI context snapshot exists. Context inspection has a lightweight context-only path so the composer and /context panel do not wait on unrelated usage or MCP status work, and desktop boot no longer blocks the first shell render on tab restoration. Constraint: Empty composer state has no live CLI context to inspect until a session starts Rejected: Display 0% before live context exists | it implied one user message consumed the fixed prompt/tool baseline Confidence: high Scope-risk: moderate Directive: Do not show numeric context usage without a real context snapshot Tested: cd desktop && bun run test -- pages.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: bun run check:server Tested: Browser reload at http://127.0.0.1:5174 with cleared open-tabs showed pending context and no /inspection request
This commit is contained in:
parent
2459488703
commit
e4f9825bc1
@ -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(<EmptySession />)
|
||||
|
||||
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(
|
||||
<ContextUsageIndicator
|
||||
chatState="idle"
|
||||
messageCount={0}
|
||||
fallbackModelLabel="kimi-k2.6"
|
||||
draft
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<EmptySession />)
|
||||
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(<ActiveSession />)
|
||||
|
||||
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(<ActiveSession />)
|
||||
|
||||
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)
|
||||
|
||||
@ -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<SessionInspectionResponse>(`/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<SessionInspectionResponse>(`/api/sessions/${sessionId}/inspection${suffix}`, {
|
||||
timeout: options?.timeout ?? (options?.includeContext ? 45_000 : 25_000),
|
||||
})
|
||||
},
|
||||
|
||||
@ -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<GitInfo | null>(null)
|
||||
@ -826,6 +829,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
chatState={chatState}
|
||||
messageCount={messageCount}
|
||||
runtimeSelectionKey={runtimeSelectionKey}
|
||||
fallbackModelLabel={runtimeModelLabel}
|
||||
compact={compact}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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<string | undefined | null>) {
|
||||
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<SessionContextSnapshot | null>(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<string | null>(null)
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null)
|
||||
const [inspectionModel, setInspectionModel] = useState<string | null>(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'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full"
|
||||
style={ringStyle}
|
||||
>
|
||||
<span className="absolute inset-[3px] rounded-full bg-[var(--color-surface-container-lowest)]" />
|
||||
<span className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full">
|
||||
{loading && !context ? (
|
||||
<span className="material-symbols-outlined relative animate-spin text-[13px]">progress_activity</span>
|
||||
<span className="absolute inset-[2px] rounded-full border-2 border-[var(--color-text-tertiary)] border-t-transparent motion-safe:animate-spin" />
|
||||
) : (
|
||||
<span
|
||||
className="relative h-[5px] w-[5px] rounded-full"
|
||||
style={{ backgroundColor: context ? strokeColor : 'var(--color-text-tertiary)' }}
|
||||
/>
|
||||
className="relative grid h-[18px] w-[18px] place-items-center rounded-full"
|
||||
style={ringStyle}
|
||||
>
|
||||
<span className="absolute inset-[3px] rounded-full bg-[var(--color-surface-container-lowest)]" />
|
||||
<span
|
||||
className="relative h-[5px] w-[5px] rounded-full"
|
||||
style={{ backgroundColor: context ? strokeColor : 'var(--color-text-tertiary)' }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] font-semibold tabular-nums">
|
||||
@ -170,7 +206,7 @@ export function ContextUsageIndicator({
|
||||
{t('contextIndicator.title')}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{context?.model ?? t('contextIndicator.modelUnknown')}
|
||||
{displayModel ?? t('contextIndicator.modelUnknown')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 font-mono text-xl font-semibold text-[var(--color-text-primary)]">
|
||||
@ -191,7 +227,7 @@ export function ContextUsageIndicator({
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
|
||||
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(maxTokens)}</div>
|
||||
<div className="mt-1 text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{details.length > 0 && (
|
||||
@ -221,9 +257,13 @@ export function ContextUsageIndicator({
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : isPendingContext ? (
|
||||
<div className="mt-4 text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{t('contextIndicator.pendingDetail')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{loading ? t('contextIndicator.loading') : error ?? t('contextIndicator.unavailableDetail')}
|
||||
{loading ? t('contextIndicator.loading') : t('contextIndicator.unavailableDetail')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -759,6 +759,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'contextIndicator.free': '剩余',
|
||||
'contextIndicator.window': '窗口',
|
||||
'contextIndicator.loading': '正在加载上下文用量...',
|
||||
'contextIndicator.pendingDetail': '会话启动后计算上下文用量。',
|
||||
'contextIndicator.unavailable': '不可用',
|
||||
'contextIndicator.unavailableDetail': '此会话暂时无法获取上下文用量。',
|
||||
'contextIndicator.updatedUnknown': '尚未刷新',
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ContextUsageIndicator
|
||||
chatState="idle"
|
||||
messageCount={0}
|
||||
runtimeSelectionKey={draftRuntimeSelectionKey}
|
||||
fallbackModelLabel={draftModelLabel}
|
||||
draft
|
||||
/>
|
||||
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting || isCreatingSession} />
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
|
||||
@ -154,7 +154,11 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as TabPersistence
|
||||
if (!data.openTabs || data.openTabs.length === 0) return
|
||||
if (!data.openTabs || data.openTabs.length === 0) {
|
||||
set({ tabs: [], activeTabId: null })
|
||||
localStorage.removeItem(TAB_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
const { sessions } = await sessionsApi.list({ limit: 200 })
|
||||
const existingIds = new Set(sessions.map((s) => s.id))
|
||||
@ -179,7 +183,11 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
}
|
||||
})
|
||||
|
||||
if (validTabs.length === 0) return
|
||||
if (validTabs.length === 0) {
|
||||
set({ tabs: [], activeTabId: null })
|
||||
localStorage.removeItem(TAB_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = data.activeTabId && validTabs.some((t) => t.sessionId === data.activeTabId)
|
||||
? data.activeTabId
|
||||
|
||||
@ -447,6 +447,29 @@ describe('WebSocket Chat Integration', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function withMockMcpStatusDelay<T>(
|
||||
delayMs: number | undefined,
|
||||
callback: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousDelay = process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
|
||||
|
||||
if (delayMs && delayMs > 0) {
|
||||
process.env.MOCK_SDK_MCP_STATUS_DELAY_MS = String(delayMs)
|
||||
} else {
|
||||
delete process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
|
||||
}
|
||||
|
||||
try {
|
||||
return await callback()
|
||||
} finally {
|
||||
if (previousDelay === undefined) {
|
||||
delete process.env.MOCK_SDK_MCP_STATUS_DELAY_MS
|
||||
} else {
|
||||
process.env.MOCK_SDK_MCP_STATUS_DELAY_MS = previousDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withMockExitAfterFirstUser<T>(
|
||||
delayMs: number | undefined,
|
||||
callback: () => Promise<T>,
|
||||
@ -819,6 +842,24 @@ describe('WebSocket Chat Integration', () => {
|
||||
expect(basicBody.context).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should expose context-only inspection without waiting on mcp status', async () => {
|
||||
await withMockMcpStatusDelay(2_000, async () => {
|
||||
const sessionId = `chat-context-only-${crypto.randomUUID()}`
|
||||
await runTurn(sessionId, 'hello before context-only inspection')
|
||||
|
||||
const startedAt = performance.now()
|
||||
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/inspection?includeContext=1&contextOnly=1`)
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as any
|
||||
|
||||
expect(body.context.model).toBe('mock-opus')
|
||||
expect(body.context.estimateOnly).toBe(true)
|
||||
expect(body.usage).toBeUndefined()
|
||||
expect(elapsedMs).toBeLessThan(1_500)
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete the client turn when the CLI exits after startup', async () => {
|
||||
const messages = await withMockExitAfterFirstUser(50, () =>
|
||||
runTurnUntilComplete(`chat-late-exit-${crypto.randomUUID()}`, 'trigger late exit'),
|
||||
|
||||
@ -28,6 +28,7 @@ const initMode = process.env.MOCK_SDK_INIT_MODE || 'on_open'
|
||||
const streamDelayMs = Number(process.env.MOCK_SDK_STREAM_DELAY_MS || '0')
|
||||
const exitAfterOpenMs = Number(process.env.MOCK_SDK_EXIT_AFTER_OPEN_MS || '0')
|
||||
const exitAfterFirstUserMs = Number(process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS || '0')
|
||||
const mcpStatusDelayMs = Number(process.env.MOCK_SDK_MCP_STATUS_DELAY_MS || '0')
|
||||
const startupStdout = process.env.MOCK_SDK_STARTUP_STDOUT || ''
|
||||
const exitBeforeSdkMs = Number(process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS || '0')
|
||||
let initSent = false
|
||||
@ -288,6 +289,9 @@ ws.addEventListener('message', (event) => {
|
||||
}
|
||||
|
||||
if (parsed.type === 'control_request' && parsed.request?.subtype === 'mcp_status') {
|
||||
if (mcpStatusDelayMs > 0) {
|
||||
await delay(mcpStatusDelayMs)
|
||||
}
|
||||
emit(ws, {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
|
||||
@ -349,6 +349,7 @@ async function getSessionSlashCommands(sessionId: string): Promise<Response> {
|
||||
|
||||
async function getSessionInspection(sessionId: string, url: URL): Promise<Response> {
|
||||
const includeContext = url.searchParams.get('includeContext') !== '0'
|
||||
const contextOnly = includeContext && url.searchParams.get('contextOnly') === '1'
|
||||
const workDir =
|
||||
conversationService.getSessionWorkDir(sessionId) ||
|
||||
await sessionService.getSessionWorkDir(sessionId)
|
||||
@ -411,46 +412,58 @@ async function getSessionInspection(sessionId: string, url: URL): Promise<Respon
|
||||
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' }, basicControlTimeoutMs),
|
||||
includeContext
|
||||
? conversationService.requestControl(
|
||||
sessionId,
|
||||
{ subtype: 'get_context_usage', estimateOnly: true },
|
||||
20_000,
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
|
||||
])
|
||||
|
||||
const errors: Record<string, string> = {}
|
||||
if (usageResult.status === 'fulfilled') {
|
||||
response.usage = chooseRicherUsage(
|
||||
{ ...usageResult.value, source: 'current_process' },
|
||||
transcriptUsage,
|
||||
)
|
||||
} else {
|
||||
if (transcriptUsage) {
|
||||
response.usage = transcriptUsage
|
||||
} else {
|
||||
errors.usage = usageResult.reason instanceof Error ? usageResult.reason.message : String(usageResult.reason)
|
||||
if (contextOnly) {
|
||||
try {
|
||||
response.context = await conversationService.requestControl(
|
||||
sessionId,
|
||||
{ subtype: 'get_context_usage', estimateOnly: true },
|
||||
20_000,
|
||||
)
|
||||
} catch (error) {
|
||||
errors.context = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
const basicControlTimeoutMs = includeContext ? 10_000 : 4_000
|
||||
const [usageResult, contextResult, mcpResult] = await Promise.allSettled([
|
||||
conversationService.requestControl(sessionId, { subtype: 'get_session_usage' }, basicControlTimeoutMs),
|
||||
includeContext
|
||||
? conversationService.requestControl(
|
||||
sessionId,
|
||||
{ subtype: 'get_context_usage', estimateOnly: true },
|
||||
20_000,
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
conversationService.requestControl(sessionId, { subtype: 'mcp_status' }, basicControlTimeoutMs),
|
||||
])
|
||||
|
||||
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
|
||||
response.status = {
|
||||
...response.status,
|
||||
mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record<string, unknown>).mcpServers,
|
||||
if (usageResult.status === 'fulfilled') {
|
||||
response.usage = chooseRicherUsage(
|
||||
{ ...usageResult.value, source: 'current_process' },
|
||||
transcriptUsage,
|
||||
)
|
||||
} else {
|
||||
if (transcriptUsage) {
|
||||
response.usage = transcriptUsage
|
||||
} else {
|
||||
errors.usage = usageResult.reason instanceof Error ? usageResult.reason.message : String(usageResult.reason)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if (mcpResult.status === 'fulfilled' && response.status && typeof response.status === 'object') {
|
||||
response.status = {
|
||||
...response.status,
|
||||
mcpServers: Array.isArray(mcpResult.value.mcpServers) ? mcpResult.value.mcpServers : (response.status as Record<string, unknown>).mcpServers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user