From 611f09a1a737bd6c53185d96f9f711957fe729de 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: Thu, 14 May 2026 22:42:01 +0800 Subject: [PATCH] Keep long desktop sessions responsive while honoring thinking controls Virtualized chat rendering keeps inactive long-running sessions cheap without disconnecting their CLI process, while DeepSeek now relies on the shared Thinking setting instead of a provider-specific disabled-thinking override. Existing legacy DeepSeek managed env is normalized so old local settings do not keep suppressing reasoning output after upgrade. Constraint: Multiple desktop tabs must keep live sessions running and remain quick to switch without reconnecting. Rejected: Pause or disconnect hidden sessions | would delay tab switching and interrupt streaming/tool state visibility. Rejected: Keep DeepSeek disabled-thinking preset | conflicts with the General Settings Thinking control. Confidence: high Scope-risk: moderate Directive: Do not reintroduce provider-specific disabled-thinking defaults for DeepSeek without testing both General Settings toggle states. Tested: bun test src/server/__tests__/conversations.test.ts -t "global Thinking setting control DeepSeek" Tested: bun test src/server/__tests__/persistence-upgrade.test.ts src/server/__tests__/provider-presets.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/title-service.test.ts src/utils/__tests__/thinking.test.ts src/utils/__tests__/providerManagedEnvCompat.test.ts Tested: cd desktop && bun run test -- MessageList.test.tsx ContextUsageIndicator.test.tsx Tested: bun run check:server --- .../chat/ContextUsageIndicator.test.tsx | 268 ++++++++++++++++++ .../components/chat/ContextUsageIndicator.tsx | 107 +++++-- .../src/components/chat/MessageList.test.tsx | 102 +++++++ desktop/src/components/chat/MessageList.tsx | 85 +++++- src/server/__tests__/conversations.test.ts | 84 ++++++ .../__tests__/persistence-upgrade.test.ts | 44 +++ src/server/__tests__/provider-presets.test.ts | 5 +- src/server/__tests__/providers.test.ts | 30 ++ src/server/__tests__/title-service.test.ts | 52 +++- src/server/config/providerPresets.json | 4 +- .../services/persistentStorageMigrations.ts | 5 + .../providerManagedEnvCompat.test.ts | 39 +++ src/utils/__tests__/thinking.test.ts | 13 + src/utils/managedEnv.ts | 3 +- src/utils/providerManagedEnvCompat.ts | 39 +++ 15 files changed, 842 insertions(+), 38 deletions(-) create mode 100644 desktop/src/components/chat/ContextUsageIndicator.test.tsx create mode 100644 src/utils/__tests__/providerManagedEnvCompat.test.ts create mode 100644 src/utils/providerManagedEnvCompat.ts diff --git a/desktop/src/components/chat/ContextUsageIndicator.test.tsx b/desktop/src/components/chat/ContextUsageIndicator.test.tsx new file mode 100644 index 00000000..cdc67efc --- /dev/null +++ b/desktop/src/components/chat/ContextUsageIndicator.test.tsx @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +const { sessionsApiMock } = vi.hoisted(() => ({ + sessionsApiMock: { + getInspection: vi.fn(), + }, +})) + +vi.mock('../../api/sessions', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + sessionsApi: { + ...actual.sessionsApi, + getInspection: sessionsApiMock.getInspection, + }, + } +}) + +import { ContextUsageIndicator } from './ContextUsageIndicator' +import { useSettingsStore } from '../../stores/settingsStore' + +const baseInspection = { + active: true, + status: { + sessionId: 'session-1', + workDir: '/workspace/project', + cwd: '/workspace/project', + permissionMode: 'bypassPermissions' as const, + model: 'kimi-k2.6', + }, + context: { + categories: [{ name: 'Messages', tokens: 42_000, color: '#2D628F' }], + totalTokens: 42_000, + maxTokens: 200_000, + rawMaxTokens: 200_000, + percentage: 21, + gridRows: [], + model: 'kimi-k2.6', + memoryFiles: [], + mcpTools: [], + agents: [], + }, +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +describe('ContextUsageIndicator request behavior', () => { + const originalVisibility = document.visibilityState + + beforeEach(() => { + vi.clearAllMocks() + useSettingsStore.setState({ locale: 'en' }) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + }) + + afterEach(() => { + cleanup() + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: originalVisibility, + }) + }) + + it('does not auto-fetch context while the document is hidden', async () => { + sessionsApiMock.getInspection.mockResolvedValue(baseInspection) + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + + render( + , + ) + + await act(async () => { + await Promise.resolve() + }) + expect(sessionsApiMock.getInspection).not.toHaveBeenCalled() + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }) + + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')) + await Promise.resolve() + }) + + await waitFor(() => { + expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1) + }) + expect(sessionsApiMock.getInspection).toHaveBeenCalledWith('session-1', { + includeContext: true, + contextOnly: true, + timeout: 20_000, + }) + }) + + it('reuses the in-flight auto inspection during session-load rerenders', async () => { + sessionsApiMock.getInspection.mockImplementation(() => new Promise(() => {})) + + const { rerender } = render( + , + ) + + await act(async () => { + await Promise.resolve() + }) + expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + await act(async () => { + await Promise.resolve() + }) + expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1) + }) + + it('starts a new auto inspection when the runtime identity changes', async () => { + sessionsApiMock.getInspection.mockImplementation(() => new Promise(() => {})) + + const { rerender } = render( + , + ) + + await act(async () => { + await Promise.resolve() + }) + expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1) + + rerender( + , + ) + + await act(async () => { + await Promise.resolve() + }) + expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(2) + }) + + it('ignores a stale inspection response after the runtime identity changes', async () => { + const first = deferred() + sessionsApiMock.getInspection + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ + ...baseInspection, + context: { ...baseInspection.context, percentage: 21 }, + }) + + const { rerender } = render( + , + ) + + await act(async () => { + await Promise.resolve() + }) + + rerender( + , + ) + + await waitFor(() => { + expect(screen.getAllByText('21%').length).toBeGreaterThan(0) + }) + + await act(async () => { + first.resolve({ + ...baseInspection, + context: { ...baseInspection.context, percentage: 90 }, + }) + await first.promise + }) + + expect(screen.getAllByText('21%').length).toBeGreaterThan(0) + expect(screen.queryByText('90%')).not.toBeInTheDocument() + }) + + it('ignores a stale inspection response when identity changes while hidden', async () => { + const first = deferred() + sessionsApiMock.getInspection.mockReturnValueOnce(first.promise) + + const { rerender } = render( + , + ) + + await act(async () => { + await Promise.resolve() + }) + expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1) + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }) + + rerender( + , + ) + + await act(async () => { + first.resolve({ + ...baseInspection, + context: { ...baseInspection.context, percentage: 90 }, + }) + await first.promise + }) + + expect(sessionsApiMock.getInspection).toHaveBeenCalledTimes(1) + expect(screen.queryByText('90%')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/chat/ContextUsageIndicator.tsx b/desktop/src/components/chat/ContextUsageIndicator.tsx index 512894e3..489b71c4 100644 --- a/desktop/src/components/chat/ContextUsageIndicator.tsx +++ b/desktop/src/components/chat/ContextUsageIndicator.tsx @@ -16,6 +16,7 @@ type Props = { const ACTIVE_REFRESH_MS = 30_000 const CONTEXT_REQUEST_TIMEOUT_MS = 20_000 +const AUTO_REFRESH_MIN_INTERVAL_MS = 10_000 function formatNumber(value: number | undefined) { return new Intl.NumberFormat().format(value ?? 0) @@ -50,6 +51,10 @@ function isCliNotRunningError(error: string | null) { return error?.toLowerCase().includes('cli session is not running') ?? false } +function isDocumentVisible() { + return typeof document === 'undefined' || document.visibilityState !== 'hidden' +} + function shouldFetchContext(sessionId: string | undefined, draft: boolean) { return Boolean(sessionId) && !draft } @@ -73,64 +78,104 @@ export function ContextUsageIndicator({ const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false) const requestSeq = useRef(0) const contextIdentityRef = useRef('') + const inFlightRequestRef = useRef | null>(null) + const inFlightIdentityRef = useRef(null) + const lastAutoRefreshAtRef = useRef(0) - const refresh = useCallback(async () => { + const refresh = useCallback(async (mode: 'auto' | 'manual' = 'manual') => { if (!sessionId || draft) { setLoading(false) return } + if (mode === 'auto' && !isDocumentVisible()) { + setLoading(false) + return + } + if (mode === 'auto' && Date.now() - lastAutoRefreshAtRef.current < AUTO_REFRESH_MIN_INTERVAL_MS) { + return inFlightRequestRef.current ?? undefined + } + if (typeof sessionsApi.getInspection !== 'function') { + setLoading(false) + return + } const activeSessionId = sessionId + const activeContextIdentity = `${activeSessionId}:${runtimeSelectionKey}` + if (inFlightRequestRef.current && inFlightIdentityRef.current === activeContextIdentity) { + return inFlightRequestRef.current + } const seq = requestSeq.current + 1 requestSeq.current = seq + if (mode === 'auto') lastAutoRefreshAtRef.current = Date.now() setLoading(true) setError(null) - try { - const inspection = await sessionsApi.getInspection(activeSessionId, { - includeContext: true, - contextOnly: true, - timeout: CONTEXT_REQUEST_TIMEOUT_MS, + const request = sessionsApi.getInspection(activeSessionId, { + includeContext: true, + contextOnly: true, + timeout: CONTEXT_REQUEST_TIMEOUT_MS, + }) + .then((inspection) => { + if (seq !== requestSeq.current || activeContextIdentity !== contextIdentityRef.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()) }) - 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) { - if (seq !== requestSeq.current) return - setError(err instanceof Error ? err.message : String(err)) - } finally { - if (seq === requestSeq.current) setLoading(false) - } - }, [draft, sessionId]) + .catch((err) => { + if (seq !== requestSeq.current || activeContextIdentity !== contextIdentityRef.current) return + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (inFlightRequestRef.current === request) { + inFlightRequestRef.current = null + inFlightIdentityRef.current = null + } + if (seq === requestSeq.current) setLoading(false) + }) + inFlightRequestRef.current = request + inFlightIdentityRef.current = activeContextIdentity + return request + }, [draft, runtimeSelectionKey, sessionId]) useEffect(() => { const contextIdentity = `${sessionId}:${runtimeSelectionKey}` const identityChanged = contextIdentityRef.current !== contextIdentity contextIdentityRef.current = contextIdentity if (identityChanged) { + requestSeq.current += 1 + lastAutoRefreshAtRef.current = 0 setContext(null) setContextSource(null) setError(null) setUpdatedAt(null) setInspectionModel(null) } - void refresh() + void refresh('auto') }, [messageCount, refresh, runtimeSelectionKey, sessionId]) + useEffect(() => { + if (typeof document === 'undefined') return + const refreshIfVisible = () => { + if (!isDocumentVisible()) return + void refresh('auto') + } + document.addEventListener('visibilitychange', refreshIfVisible) + return () => document.removeEventListener('visibilitychange', refreshIfVisible) + }, [refresh]) + useEffect(() => { if (chatState === 'idle') return const timer = setInterval(() => { - void refresh() + void refresh('auto') }, ACTIVE_REFRESH_MS) return () => clearInterval(timer) }, [chatState, messageCount, refresh]) @@ -178,7 +223,7 @@ export function ContextUsageIndicator({ if (compact) { setMobileDetailsOpen(true) } - void refresh() + void refresh('manual') }} title={t('contextIndicator.title')} data-testid="context-usage-indicator" diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index c7cdb5b8..22fffacc 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -119,6 +119,108 @@ describe('MessageList nested tool calls', () => { }) }) + it('window-renders long transcripts while keeping the latest messages visible', () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: Array.from({ length: 220 }, (_, index) => ({ + id: `assistant-${index}`, + type: 'assistant_text', + content: `assistant transcript line ${index}`, + timestamp: index, + })), + }), + }, + }) + + const { container } = render() + + expect(screen.queryByText('assistant transcript line 0')).toBeNull() + expect(screen.getByText('assistant transcript line 219')).toBeTruthy() + expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBeLessThan(140) + }) + + it('restores the full transcript when scrolling away from latest', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: Array.from({ length: 220 }, (_, index) => ({ + id: `assistant-${index}`, + type: 'assistant_text', + content: `assistant transcript line ${index}`, + timestamp: index, + })), + }), + }, + }) + + const { container } = render() + const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement + Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 }) + Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 220 * 112 }) + await waitForProgrammaticScrollReset() + + scrollArea.scrollTop = 0 + await act(async () => { + fireEvent.scroll(scrollArea) + }) + + expect(screen.getByText('assistant transcript line 0')).toBeTruthy() + expect(screen.getByText('assistant transcript line 219')).toBeTruthy() + expect(container.querySelectorAll('[data-message-shell="assistant"]').length).toBe(220) + }) + + it('window-renders long histories that include tool-call groups', async () => { + useChatStore.setState({ + sessions: { + [ACTIVE_TAB]: makeSessionState({ + messages: [ + { + id: 'tool-read', + type: 'tool_use', + toolName: 'Read', + toolUseId: 'read-1', + input: { file_path: '/tmp/example.ts' }, + timestamp: 0, + }, + { + id: 'tool-read-result', + type: 'tool_result', + toolUseId: 'read-1', + content: 'read result content', + isError: false, + timestamp: 1, + }, + ...Array.from({ length: 220 }, (_, index) => ({ + id: `assistant-${index}`, + type: 'assistant_text' as const, + content: `assistant transcript line ${index}`, + timestamp: index + 2, + })), + ], + }), + }, + }) + + const { container } = render() + const scrollArea = container.querySelector('.chat-scroll-area') as HTMLElement + Object.defineProperty(scrollArea, 'clientHeight', { configurable: true, value: 500 }) + Object.defineProperty(scrollArea, 'scrollHeight', { configurable: true, value: 222 * 112 }) + await waitForProgrammaticScrollReset() + + expect(screen.queryByText('Read')).toBeNull() + expect(screen.getByText('assistant transcript line 219')).toBeTruthy() + + scrollArea.scrollTop = 0 + await act(async () => { + fireEvent.scroll(scrollArea) + }) + + expect(screen.getByText('Read')).toBeTruthy() + expect(screen.getByText('assistant transcript line 219')).toBeTruthy() + expect(container.querySelectorAll('[data-virtual-message-item]').length).toBe(221) + }) + it('renders sub-agent tool calls inline beneath the parent agent tool call', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index c95cf66b..ad66286e 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -465,6 +465,8 @@ type MessageListProps = { const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48 const MAX_SCROLL_SNAPSHOTS = 100 +const VIRTUALIZE_MIN_ITEMS = 160 +const VIRTUAL_INITIAL_ITEMS = 96 const CHAT_SCROLL_AREA_CLASS = [ 'chat-scroll-area', '[scrollbar-width:auto]', @@ -512,6 +514,10 @@ function clampScrollTop(element: HTMLElement, scrollTop: number) { return Math.max(0, Math.min(scrollTop, element.scrollHeight - element.clientHeight)) } +function getRenderItemKey(item: RenderItem) { + return item.kind === 'tool_group' ? item.id : item.message.id +} + export function MessageList({ sessionId, compact = false }: MessageListProps = {}) { const activeTabId = useTabStore((s) => s.activeTabId) const resolvedSessionId = sessionId ?? activeTabId @@ -535,6 +541,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const shouldAutoScrollRef = useRef(true) const isProgrammaticScrollingRef = useRef(false) const lastSessionIdRef = useRef(resolvedSessionId) + const updateVirtualWindowRef = useRef<((element: HTMLElement) => void) | null>(null) const t = useTranslation() const [turnChangeCards, setTurnChangeCards] = useState([]) const [turnChangeLoadError, setTurnChangeLoadError] = useState(null) @@ -575,6 +582,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { if (resolvedSessionId) { rememberSessionScroll(resolvedSessionId, container) } + updateVirtualWindowRef.current?.(container) }, [resolvedSessionId]) useLayoutEffect(() => { @@ -610,6 +618,58 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { () => buildRenderModel(messages), [messages], ) + const shouldVirtualize = renderItems.length > VIRTUALIZE_MIN_ITEMS + const [virtualRange, setVirtualRange] = useState(() => ({ start: 0, end: 0 })) + + const updateVirtualWindow = useCallback((container: HTMLElement | null) => { + if (!shouldVirtualize) { + setVirtualRange((current) => + current.start === 0 && current.end === renderItems.length + ? current + : { start: 0, end: renderItems.length }, + ) + return + } + + if (!container || shouldAutoScrollRef.current) { + const start = Math.max(0, renderItems.length - VIRTUAL_INITIAL_ITEMS) + setVirtualRange((current) => + current.start === start && current.end === renderItems.length + ? current + : { start, end: renderItems.length }, + ) + return + } + + setVirtualRange((current) => + current.start === 0 && current.end === renderItems.length + ? current + : { start: 0, end: renderItems.length }, + ) + }, [renderItems.length, shouldVirtualize]) + + useEffect(() => { + updateVirtualWindowRef.current = updateVirtualWindow + return () => { + if (updateVirtualWindowRef.current === updateVirtualWindow) { + updateVirtualWindowRef.current = null + } + } + }, [updateVirtualWindow]) + + useLayoutEffect(() => { + updateVirtualWindow(scrollContainerRef.current) + }, [renderItems.length, resolvedSessionId, updateVirtualWindow]) + + const visibleRenderItems = shouldVirtualize + ? renderItems.slice(virtualRange.start, virtualRange.end) + : renderItems + const topVirtualSpacerHeight = shouldVirtualize + ? Math.max(0, virtualRange.start * 96) + : 0 + const bottomVirtualSpacerHeight = shouldVirtualize + ? Math.max(0, renderItems.length - virtualRange.end) * 96 + : 0 const completedTurnTargets = useMemo(() => getCompletedTurnTargets(messages), [messages]) const latestCompletedTurnId = completedTurnTargets.length > 0 @@ -757,12 +817,27 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { onScroll={updateAutoScrollState} className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`} > -
- {renderItems.map((item, index) => { +
+ {topVirtualSpacerHeight > 0 && ( +