diff --git a/desktop/scripts/e2e-context-usage-live-agent-browser.sh b/desktop/scripts/e2e-context-usage-live-agent-browser.sh new file mode 100755 index 00000000..6b87cc85 --- /dev/null +++ b/desktop/scripts/e2e-context-usage-live-agent-browser.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +DESKTOP_DIR="$ROOT_DIR/desktop" + +if ! command -v agent-browser >/dev/null 2>&1; then + echo "agent-browser is required but was not found in PATH" >&2 + exit 1 +fi + +PORTS="$(bun -e 'import { createServer } from "node:net"; const getPort = () => new Promise((resolve) => { const server = createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close(() => resolve(port)); }); }); const apiPort = await getPort(); const webPort = await getPort(); console.log(`${apiPort} ${webPort}`);')" +API_PORT="${PORTS%% *}" +WEB_PORT="${PORTS##* }" +BASE_URL="http://127.0.0.1:${API_PORT}" +WEB_URL="http://127.0.0.1:${WEB_PORT}/?serverUrl=${BASE_URL}" + +RUN_ID="$(date +%s)-$RANDOM" +SESSION_NAME="cc-haha-context-live-${RUN_ID}" +ARTIFACT_DIR="${ARTIFACT_DIR:-$(mktemp -d "/tmp/cc-haha-context-live-${RUN_ID}-XXXX")}" +PROJECT_DIR="${ARTIFACT_DIR}/project" +SERVER_LOG="${ARTIFACT_DIR}/server.log" +WEB_LOG="${ARTIFACT_DIR}/web.log" +BROWSER_LOG="${ARTIFACT_DIR}/browser.log" +RESULT_FILE="${PROJECT_DIR}/context-live-result.txt" + +cleanup() { + local exit_code=$? + if [[ -n "${PREVIOUS_PERMISSION_MODE:-}" ]]; then + curl -fsS -X PUT "${BASE_URL}/api/permissions/mode" \ + -H 'Content-Type: application/json' \ + -d "{\"mode\":\"${PREVIOUS_PERMISSION_MODE}\"}" >/dev/null 2>&1 || true + fi + if [[ -n "${SESSION_ID:-}" && "${KEEP_SESSION:-0}" != "1" ]]; then + curl -fsS -X DELETE "${BASE_URL}/api/sessions/${SESSION_ID}" >/dev/null 2>&1 || true + fi + agent-browser --session "${SESSION_NAME}" close >/dev/null 2>&1 || true + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${WEB_PID:-}" ]]; then + kill "${WEB_PID}" >/dev/null 2>&1 || true + wait "${WEB_PID}" >/dev/null 2>&1 || true + fi + if [[ $exit_code -ne 0 || "${KEEP_ARTIFACTS:-0}" == "1" ]]; then + echo "Artifacts kept at: ${ARTIFACT_DIR}" >&2 + else + rm -rf "${ARTIFACT_DIR}" + fi +} +trap cleanup EXIT + +mkdir -p "${PROJECT_DIR}" +cat > "${PROJECT_DIR}/README.md" <<'EOF' +# Context Usage Live E2E + +This throwaway project is used by a real-model desktop WebUI test. +EOF + +echo "Starting backend on ${BASE_URL}" +( + cd "${ROOT_DIR}" + SERVER_PORT="${API_PORT}" bun run src/server/index.ts --host 127.0.0.1 --port "${API_PORT}" +) >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo "Starting web UI on http://127.0.0.1:${WEB_PORT}" +( + cd "${DESKTOP_DIR}" + bun run dev -- --host 127.0.0.1 --port "${WEB_PORT}" --strictPort +) >"${WEB_LOG}" 2>&1 & +WEB_PID=$! + +wait_for_http() { + local url="$1" + for _ in $(seq 1 120); do + if curl --max-time 2 -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + echo "Timed out waiting for ${url}" >&2 + return 1 +} + +fetch_json_field() { + local url="$1" + local expression="$2" + curl -fsS "$url" | bun -e "const input = await Bun.stdin.text(); const data = JSON.parse(input); ${expression}" +} + +AB=(agent-browser --session "${SESSION_NAME}") + +browser_text() { + "${AB[@]}" get text body 2>>"${BROWSER_LOG}" +} + +wait_for_text() { + local needle="$1" + local attempts="${2:-120}" + for _ in $(seq 1 "${attempts}"); do + if browser_text | grep -Fq "$needle"; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for page text: ${needle}" >&2 + return 1 +} + +session_contains() { + local needle="$1" + curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/messages" | NEEDLE="${needle}" bun -e ' + const input = await Bun.stdin.text() + const data = JSON.parse(input) + const haystack = JSON.stringify(data) + process.exit(haystack.includes(process.env.NEEDLE ?? "") ? 0 : 1) + ' +} + +wait_for_session_text() { + local needle="$1" + local attempts="${2:-180}" + for _ in $(seq 1 "${attempts}"); do + if session_contains "${needle}"; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for transcript text: ${needle}" >&2 + return 1 +} + +context_label() { + "${AB[@]}" eval "document.querySelector('[aria-label^=\"Context usage\"], [aria-label^=\"上下文用量\"]')?.getAttribute('aria-label') || ''" 2>>"${BROWSER_LOG}" || true +} + +wait_for_context_indicator() { + for _ in $(seq 1 120); do + if context_label | grep -Fq "Context usage"; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for context usage indicator" >&2 + return 1 +} + +show_context_details() { + "${AB[@]}" eval "const el = document.querySelector('[aria-label^=\"Context usage\"], [aria-label^=\"上下文用量\"]'); if (el) { el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); el.focus(); }" >>"${BROWSER_LOG}" 2>&1 || true +} + +wait_for_context_window_text() { + for _ in $(seq 1 80); do + show_context_details + if browser_text | grep -Fq "Window"; then + return 0 + fi + sleep 0.5 + done + echo "Timed out waiting for context usage detail popover" >&2 + return 1 +} + +wait_for_result_file() { + for _ in $(seq 1 240); do + if [[ -f "${RESULT_FILE}" ]] && grep -Fq "context-live-ok" "${RESULT_FILE}"; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for real model tool execution" >&2 + return 1 +} + +wait_for_compact_boundary() { + for _ in $(seq 1 240); do + if session_contains "compact_boundary" || session_contains "Context compacted" || session_contains "Compacted"; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for real /compact boundary" >&2 + return 1 +} + +wait_for_http "${BASE_URL}/health" +wait_for_http "http://127.0.0.1:${WEB_PORT}" + +PREVIOUS_PERMISSION_MODE="$(fetch_json_field "${BASE_URL}/api/permissions/mode" 'console.log(data.mode)')" +curl -fsS -X PUT "${BASE_URL}/api/permissions/mode" \ + -H 'Content-Type: application/json' \ + -d '{"mode":"bypassPermissions"}' >/dev/null + +SESSION_ID="$(curl -fsS -X POST "${BASE_URL}/api/sessions" \ + -H 'Content-Type: application/json' \ + -d "{\"workDir\":\"${PROJECT_DIR//\"/\\\"}\"}" \ + | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); console.log(data.sessionId);')" +UNIQUE_TITLE="Context Live ${RUN_ID}" +curl -fsS -X PATCH "${BASE_URL}/api/sessions/${SESSION_ID}" \ + -H 'Content-Type: application/json' \ + -d "{\"title\":\"${UNIQUE_TITLE//\"/\\\"}\"}" >/dev/null + +CURRENT_MODEL="$(fetch_json_field "${BASE_URL}/api/models/current" 'console.log(data.model?.name || data.model?.id || "current")')" +TAB_STATE="{\"openTabs\":[{\"sessionId\":\"${SESSION_ID}\",\"title\":\"${UNIQUE_TITLE}\",\"type\":\"session\"}],\"activeTabId\":\"${SESSION_ID}\"}" + +"${AB[@]}" open "${WEB_URL}" >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" wait 1200 >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" eval "localStorage.setItem('cc-haha-locale', 'en'); localStorage.setItem('cc-haha-open-tabs', '${TAB_STATE}'); location.reload();" >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" wait 1800 >>"${BROWSER_LOG}" 2>&1 + +"${AB[@]}" fill '#sidebar-search' "${UNIQUE_TITLE}" >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" eval "Array.from(document.querySelectorAll('button')).find((el) => el.textContent?.includes('Context Live'))?.click()" >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" wait 1200 >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" wait 'textarea' >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" screenshot "${ARTIFACT_DIR}/01-session-open.png" >/dev/null + +wait_for_context_indicator +show_context_details +wait_for_context_window_text +"${AB[@]}" screenshot "${ARTIFACT_DIR}/02-initial-real-context.png" >/dev/null + +PROMPT="Use Bash to run exactly: sleep 18 && printf context-live-ok > context-live-result.txt . After the command finishes, reply exactly CONTEXT_LIVE_DONE. Do not edit any other file." +"${AB[@]}" fill 'textarea' "${PROMPT}" >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" press 'Enter' >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" wait 3500 >>"${BROWSER_LOG}" 2>&1 + +wait_for_context_indicator +show_context_details +wait_for_context_window_text +"${AB[@]}" screenshot "${ARTIFACT_DIR}/03-running-real-model-context.png" >/dev/null + +wait_for_result_file +wait_for_session_text "CONTEXT_LIVE_DONE" 180 +"${AB[@]}" wait 1000 >>"${BROWSER_LOG}" 2>&1 +show_context_details +"${AB[@]}" screenshot "${ARTIFACT_DIR}/04-after-real-model-turn-context.png" >/dev/null + +COMPACT_PROMPT="/compact Keep a concise summary that mentions context-live-ok and the Bash command result." +"${AB[@]}" fill 'textarea' "${COMPACT_PROMPT}" >>"${BROWSER_LOG}" 2>&1 +"${AB[@]}" press 'Enter' >>"${BROWSER_LOG}" 2>&1 +wait_for_compact_boundary +show_context_details +wait_for_context_window_text +"${AB[@]}" screenshot "${ARTIFACT_DIR}/05-after-real-compact-context.png" >/dev/null + +INSPECTION_SUMMARY="$(curl -fsS "${BASE_URL}/api/sessions/${SESSION_ID}/inspection?includeContext=1" | bun -e 'const input = await Bun.stdin.text(); const data = JSON.parse(input); const ctx = data.context || data.contextEstimate; console.log(JSON.stringify({ model: ctx?.model, totalTokens: ctx?.totalTokens, rawMaxTokens: ctx?.rawMaxTokens, percentage: ctx?.percentage, categories: ctx?.categories?.slice(0, 5)?.map((c) => ({ name: c.name, tokens: c.tokens })) }, null, 2));')" + +echo "Context usage live WebUI E2E passed" +echo "Model: ${CURRENT_MODEL}" +echo "Session: ${SESSION_ID}" +echo "API port: ${API_PORT}" +echo "Web port: ${WEB_PORT}" +echo "Artifacts: ${ARTIFACT_DIR}" +echo "Inspection:" +echo "${INSPECTION_SUMMARY}" diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 6372070d..055400b7 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, it, expect, vi } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { skillsApi } from '../api/skills' @@ -78,10 +78,12 @@ import { UserMessage } from '../components/chat/UserMessage' import { useChatStore } from '../stores/chatStore' import { useSettingsStore } from '../stores/settingsStore' import { useSessionStore } from '../stores/sessionStore' +import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore' import { useTabStore } from '../stores/tabStore' beforeEach(() => { useSettingsStore.setState({ locale: 'en' }) + useSessionRuntimeStore.setState({ selections: {} }) }) /** @@ -601,6 +603,337 @@ describe('Content-only pages render without errors', () => { useChatStore.setState({ sessions: {} }) }) + it('ActiveSession shows live context usage near the composer', async () => { + const SESSION_ID = 'context-indicator-session' + vi.mocked(sessionsApi.getInspection).mockResolvedValueOnce({ + active: true, + status: { + sessionId: SESSION_ID, + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + model: 'kimi-k2.6', + }, + context: { + categories: [ + { name: 'Messages', tokens: 42_000, color: '#2D628F' }, + { name: 'Tools', tokens: 8_000, color: '#8F482F' }, + { name: 'Free space', tokens: 70_000, color: '#9B928C' }, + ], + totalTokens: 50_000, + maxTokens: 120_000, + rawMaxTokens: 120_000, + percentage: 42, + gridRows: [], + model: 'kimi-k2.6', + memoryFiles: [], + mcpTools: [], + agents: [], + }, + }) + + 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, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [{ id: 'm-1', type: 'assistant_text', content: 'done', timestamp: Date.now() }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 50_000, output_tokens: 1_000 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + expect(await screen.findByLabelText('Context usage 42%')).toBeInTheDocument() + expect(screen.getByText('120,000')).toBeInTheDocument() + expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, { + includeContext: true, + timeout: 45_000, + }) + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + + it('ActiveSession keeps a stable context placeholder while context usage loads', async () => { + const SESSION_ID = 'context-loading-session' + vi.mocked(sessionsApi.getInspection).mockImplementationOnce(() => new Promise(() => {})) + + 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 loading') + expect(indicator).toHaveTextContent('--') + expect(indicator).toHaveClass('h-8') + + 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({ + active: false, + status: { + sessionId: SESSION_ID, + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + model: 'deepseek-v4-pro', + }, + contextEstimate: { + categories: [ + { name: 'Messages', tokens: 72_000, color: '#2D628F' }, + { name: 'Autocompact buffer', tokens: 24_000, color: '#9B928C', isDeferred: true }, + ], + totalTokens: 72_000, + maxTokens: 1_000_000, + rawMaxTokens: 1_000_000, + percentage: 7, + gridRows: [], + model: 'deepseek-v4-pro', + memoryFiles: [], + mcpTools: [], + agents: [], + }, + 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: 4, + projectPath: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: SESSION_ID, + isLoading: false, + error: null, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [ + { id: 'm-1', type: 'system', content: 'Context compacted', timestamp: Date.now() }, + { id: 'm-2', 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: 72_000, output_tokens: 2_000 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + expect(await screen.findByLabelText('Context usage 7%')).toBeInTheDocument() + expect(screen.getByText('deepseek-v4-pro')).toBeInTheDocument() + expect(screen.getByText('1,000,000')).toBeInTheDocument() + expect(screen.getByText('Estimate')).toBeInTheDocument() + expect(screen.queryByText('Autocompact buffer')).not.toBeInTheDocument() + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + }) + + it('ActiveSession refreshes context usage when the selected runtime model changes', async () => { + const SESSION_ID = 'context-runtime-refresh-session' + vi.mocked(sessionsApi.getInspection) + .mockResolvedValueOnce({ + active: true, + status: { + sessionId: SESSION_ID, + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + model: 'kimi-k2.6', + }, + context: { + categories: [{ name: 'Messages', tokens: 26_000, color: '#2D628F' }], + totalTokens: 26_000, + maxTokens: 262_144, + rawMaxTokens: 262_144, + percentage: 10, + gridRows: [], + model: 'kimi-k2.6', + memoryFiles: [], + mcpTools: [], + agents: [], + }, + }) + .mockResolvedValueOnce({ + active: true, + status: { + sessionId: SESSION_ID, + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions', + model: 'glm-4.5-air', + }, + context: { + categories: [{ name: 'Messages', tokens: 26_000, color: '#2D628F' }], + totalTokens: 26_000, + maxTokens: 128_000, + rawMaxTokens: 128_000, + percentage: 20, + gridRows: [], + model: 'glm-4.5-air', + memoryFiles: [], + mcpTools: [], + agents: [], + }, + }) + + 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, + }) + useChatStore.setState({ + sessions: { + [SESSION_ID]: { + messages: [{ id: 'm-1', type: 'assistant_text', content: 'done', timestamp: Date.now() }], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 26_000, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + const inspectionCallsBeforeRender = vi.mocked(sessionsApi.getInspection).mock.calls.length + render() + + expect(await screen.findByLabelText('Context usage 10%')).toBeInTheDocument() + + useSessionRuntimeStore.getState().setSelection(SESSION_ID, { + providerId: 'zhipu-provider', + modelId: 'glm-4.5-air', + }) + + expect(await screen.findByLabelText('Context usage 20%')).toBeInTheDocument() + await waitFor(() => { + expect(vi.mocked(sessionsApi.getInspection).mock.calls.length - inspectionCallsBeforeRender) + .toBeGreaterThanOrEqual(2) + }) + + useTabStore.setState({ tabs: [], activeTabId: null }) + useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) + useChatStore.setState({ sessions: {} }) + useSessionRuntimeStore.setState({ selections: {} }) + }) + it('AgentTeams renders team strip and members', () => { const { container } = render() expect(container.innerHTML).toContain('Architect') diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 4d7b66c7..4e0f29d6 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -20,6 +20,7 @@ import { ProjectContextChip } from '../shared/ProjectContextChip' import { DirectoryPicker } from '../shared/DirectoryPicker' import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' +import { ContextUsageIndicator } from './ContextUsageIndicator' import { FALLBACK_SLASH_COMMANDS, findSlashTrigger, @@ -89,6 +90,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const chatState = sessionState?.chatState ?? 'idle' const slashCommands = sessionState?.slashCommands ?? [] const composerPrefill = sessionState?.composerPrefill ?? null + const messageCount = sessionState?.messages?.length ?? 0 + const runtimeSelection = useSessionRuntimeStore((state) => + activeTabId ? state.selections[activeTabId] : undefined, + ) + const runtimeSelectionKey = runtimeSelection + ? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}` + : undefined 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) @@ -812,6 +820,15 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
+ {!isMemberSession && activeTabId && ( + + )} {!isMemberSession && activeTabId && ( )} diff --git a/desktop/src/components/chat/ContextUsageIndicator.tsx b/desktop/src/components/chat/ContextUsageIndicator.tsx new file mode 100644 index 00000000..6b4b391b --- /dev/null +++ b/desktop/src/components/chat/ContextUsageIndicator.tsx @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions' +import { useTranslation } from '../../i18n' +import type { ChatState } from '../../types/chat' + +type Props = { + sessionId: string + chatState: ChatState + messageCount: number + runtimeSelectionKey?: string + compact?: boolean +} + +const ACTIVE_REFRESH_MS = 30_000 + +function formatNumber(value: number | undefined) { + return new Intl.NumberFormat().format(value ?? 0) +} + +function formatPercent(value: number | undefined) { + const percent = Math.max(0, Math.min(100, value ?? 0)) + return `${percent.toFixed(percent >= 10 || Number.isInteger(percent) ? 0 : 1)}%` +} + +function formatUpdatedAt(timestamp: number | null, t: ReturnType) { + if (!timestamp) return t('contextIndicator.updatedUnknown') + const elapsedMs = Date.now() - timestamp + if (elapsedMs < 60_000) return t('contextIndicator.updatedNow') + const minutes = Math.max(1, Math.floor(elapsedMs / 60_000)) + return t('contextIndicator.updatedMinutes', { count: minutes }) +} + +function pickUsedContextCategory(context: SessionContextSnapshot) { + const ignored = new Set(['free space', 'autocompact buffer']) + return context.categories + .filter((category) => category.tokens > 0 && !category.isDeferred && !ignored.has(category.name.toLowerCase())) + .sort((a, b) => b.tokens - a.tokens) + .slice(0, 4) +} + +export function ContextUsageIndicator({ + sessionId, + chatState, + messageCount, + runtimeSelectionKey = '', + 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 [error, setError] = useState(null) + const [updatedAt, setUpdatedAt] = useState(null) + const requestSeq = useRef(0) + const contextIdentityRef = useRef('') + + const refresh = useCallback(async () => { + if (!sessionId) return + const seq = requestSeq.current + 1 + requestSeq.current = seq + setLoading(true) + setError(null) + try { + const inspection = await sessionsApi.getInspection(sessionId, { + includeContext: true, + timeout: 45_000, + }) + if (seq !== requestSeq.current) return + const nextContext = inspection.context ?? inspection.contextEstimate ?? null + const nextSource = inspection.context ? 'live' : inspection.contextEstimate ? 'estimate' : null + setContext(nextContext) + setContextSource(nextSource) + setError(nextContext ? null : inspection.errors?.context ?? null) + setUpdatedAt(Date.now()) + } catch (err) { + if (seq !== requestSeq.current) return + setError(err instanceof Error ? err.message : String(err)) + } finally { + if (seq === requestSeq.current) setLoading(false) + } + }, [sessionId]) + + useEffect(() => { + const contextIdentity = `${sessionId}:${runtimeSelectionKey}` + const identityChanged = contextIdentityRef.current !== contextIdentity + contextIdentityRef.current = contextIdentity + if (identityChanged) { + setContext(null) + setContextSource(null) + setError(null) + setUpdatedAt(null) + } + void refresh() + if (!identityChanged) return + const retryTimer = setTimeout(() => { + void refresh() + }, 2_500) + return () => clearTimeout(retryTimer) + }, [messageCount, refresh, runtimeSelectionKey, sessionId]) + + useEffect(() => { + if (chatState === 'idle') return + const timer = setInterval(() => { + void refresh() + }, ACTIVE_REFRESH_MS) + return () => clearInterval(timer) + }, [chatState, messageCount, refresh]) + + const details = useMemo(() => { + if (!context) return [] + return pickUsedContextCategory(context) + }, [context]) + + const percentage = context ? Math.max(0, Math.min(100, context.percentage)) : 0 + const usedTokens = context?.totalTokens ?? 0 + const maxTokens = context?.rawMaxTokens ?? 0 + const freeTokens = Math.max(0, maxTokens - usedTokens) + const strokeColor = percentage >= 90 + ? 'var(--color-error)' + : percentage >= 75 + ? 'var(--color-warning)' + : 'var(--color-secondary)' + const ringStyle = { + background: context + ? `conic-gradient(${strokeColor} ${percentage * 3.6}deg, var(--color-surface-container-high) 0deg)` + : 'var(--color-surface-container-high)', + } + const displayPercent = context ? formatPercent(percentage) : '--' + const ariaLabel = context + ? t('contextIndicator.ariaLabel', { percent: formatPercent(percentage) }) + : loading + ? t('contextIndicator.loadingAria') + : t('contextIndicator.unavailableAria') + + return ( +
+ + +
+
+
+
+ {t('contextIndicator.title')} +
+
+ {context?.model ?? t('contextIndicator.modelUnknown')} +
+
+
+ {context ? formatPercent(percentage) : '--'} +
+
+ + {context ? ( + <> +
+
+
{t('contextIndicator.used')}
+
{formatNumber(usedTokens)}
+
+
+
{t('contextIndicator.free')}
+
{formatNumber(freeTokens)}
+
+
+
{t('contextIndicator.window')}
+
{formatNumber(maxTokens)}
+
+
+ {details.length > 0 && ( +
+ {details.map((category) => { + const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0 + return ( +
+
+ {category.name} + {formatNumber(category.tokens)} +
+
+
+
+
+ ) + })} +
+ )} +
+ {formatUpdatedAt(updatedAt, t)} + {contextSource === 'estimate' && ( + + {t('contextIndicator.estimate')} + + )} +
+ + ) : ( +
+ {loading ? t('contextIndicator.loading') : error ?? t('contextIndicator.unavailableDetail')} +
+ )} +
+
+ ) +} diff --git a/desktop/src/components/chat/LocalSlashCommandPanel.tsx b/desktop/src/components/chat/LocalSlashCommandPanel.tsx index 9ea2cd02..abae9374 100644 --- a/desktop/src/components/chat/LocalSlashCommandPanel.tsx +++ b/desktop/src/components/chat/LocalSlashCommandPanel.tsx @@ -297,7 +297,14 @@ function UsageTab({ {models.map((model) => (
-
{model.displayName || model.model}
+
+
{model.displayName || model.model}
+ {(model.contextWindow > 0 || context?.rawMaxTokens) && ( +
+ {t('slash.inspector.status.contextWindow')}: {formatNumber(model.contextWindow || context?.rawMaxTokens)} +
+ )} +
{t('slash.inspector.usage.tokens')}
@@ -518,7 +525,9 @@ function StatusTab({ }) { const mcpServers = Array.isArray(data.status.mcpServers) ? data.status.mcpServers : [] const tools = Array.isArray(data.status.tools) ? data.status.tools : [] - const model = data.status.model ?? data.context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown') + const context = data.context ?? data.contextEstimate + const model = data.status.model ?? context?.model ?? data.usage?.models?.[0]?.displayName ?? data.usage?.models?.[0]?.model ?? t('slash.inspector.status.unknown') + const contextWindow = context?.rawMaxTokens ?? data.usage?.models?.find((entry) => entry.contextWindow > 0)?.contextWindow const slashCommandCount = (data.status.slashCommandCount ?? 0) > 0 ? data.status.slashCommandCount : commands?.length ?? 0 @@ -536,7 +545,11 @@ function StatusTab({ )} /> - + ) : selectedTab === 'usage' ? ( - + ) : selectedTab === 'context' ? ( = { 'slash.inspector.status.default': '默认', 'slash.inspector.status.mcpServers': 'MCP 服务', 'slash.inspector.status.refresh': '刷新', + 'slash.inspector.status.contextWindow': '上下文窗口', 'slash.inspector.usage.emptyTitle': '暂无用量数据', 'slash.inspector.usage.emptyBody': 'CLI 会话启动并完成至少一轮回复后,这里会显示用量。', 'slash.inspector.usage.contextSnapshotNotice': '当前展示来自上下文快照的 token 用量。上下文数据刷新后会同步更新。', @@ -757,6 +758,21 @@ export const zh: Record = { 'slash.inspector.context.categoryTitle': '按类别估算', 'slash.inspector.context.noCategoriesTitle': '暂无上下文分类', 'slash.inspector.context.noCategoriesBody': 'CLI 返回上下文分析后,这里会显示分类数据。', + 'contextIndicator.ariaLabel': '上下文用量 {percent}', + 'contextIndicator.loadingAria': '上下文用量加载中', + 'contextIndicator.unavailableAria': '上下文用量不可用', + 'contextIndicator.title': '上下文', + 'contextIndicator.modelUnknown': '未知模型', + 'contextIndicator.used': '已使用', + 'contextIndicator.free': '剩余', + 'contextIndicator.window': '窗口', + 'contextIndicator.loading': '正在加载上下文用量...', + 'contextIndicator.unavailable': '不可用', + 'contextIndicator.unavailableDetail': '此会话暂时无法获取上下文用量。', + 'contextIndicator.updatedUnknown': '尚未刷新', + 'contextIndicator.updatedNow': '刚刚更新', + 'contextIndicator.updatedMinutes': '{count} 分钟前更新', + 'contextIndicator.estimate': '估算', 'chat.navigate': '导航', 'chat.select': '选择', 'chat.dismiss': '关闭',