From 62a5a254b34bde9b70dbee1d144301ac9b8d8875 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 27 May 2026 14:19:15 +0300 Subject: [PATCH] perf: reduce status row rerenders while streaming Keeps assistant status stable during text deltas Updates status only when activity changes Narrows todo subscriptions when hidden --- packages/ui/src/components/chat/StatusRow.tsx | 19 +- packages/ui/src/hooks/useAssistantStatus.ts | 359 ++++++++---------- 2 files changed, 167 insertions(+), 211 deletions(-) diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index ddde86f2..168ca453 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -155,19 +155,26 @@ export const StatusRow: React.FC = ({ const { t } = useI18n(); const [isExpanded, setIsExpanded] = React.useState(false); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const todosRecord = useDirectorySync((state) => state.todo); + const liveTodos = useDirectorySync( + React.useCallback( + (state) => { + if (!showTodos || !currentSessionId) return EMPTY_TODOS; + return state.todo[currentSessionId] ?? EMPTY_TODOS; + }, + [currentSessionId, showTodos], + ), + ); const persistedSessionTodos = useTodosPersistStore( React.useCallback( - (state) => (currentSessionId ? state.sessions[currentSessionId]?.todos : undefined), - [currentSessionId], + (state) => (showTodos && currentSessionId ? state.sessions[currentSessionId]?.todos : undefined), + [currentSessionId, showTodos], ), ); const todos: TodoItem[] = React.useMemo(() => { if (!currentSessionId) return EMPTY_TODOS; - const live = todosRecord[currentSessionId]; - if (live && live.length > 0) return live; + if (liveTodos.length > 0) return liveTodos; return persistedSessionTodos ?? EMPTY_TODOS; - }, [todosRecord, persistedSessionTodos, currentSessionId]); + }, [liveTodos, persistedSessionTodos, currentSessionId]); const isMobile = useUIStore((state) => state.isMobile); const isCompact = isMobile || isVSCodeRuntime(); diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index cf3c217e..516c01ea 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -1,5 +1,5 @@ import React from 'react'; -import type { AssistantMessage, Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; +import type { Message, Part, ReasoningPart, TextPart, ToolPart } from '@opencode-ai/sdk/v2'; import type { MessageStreamPhase } from '@/stores/types/sessionTypes'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -41,22 +41,6 @@ export interface AssistantStatusSnapshot { working: WorkingSummary; } -type AssistantMessageWithState = AssistantMessage & { - status?: string; - streaming?: boolean; - abortedAt?: number; -}; - -interface AssistantSessionMessageRecord { - info: AssistantMessageWithState; - parts: Part[]; -} - -type SessionMessageRecord = { - info: Message; - parts: Part[]; -}; - const DEFAULT_WORKING: WorkingSummary = { activity: 'idle', hasWorkingContext: false, @@ -81,8 +65,150 @@ const DEFAULT_WORKING: WorkingSummary = { const EMPTY_MESSAGES: Message[] = []; const EMPTY_PARTS: Part[] = []; -const EMPTY_SESSION_MESSAGES: SessionMessageRecord[] = []; -const isAssistantMessage = (message: Message): message is AssistantMessageWithState => message.role === 'assistant'; +const STATUS_SIGNATURE_SEPARATOR = '\u0000'; +const EDITING_TOOLS = new Set(['edit', 'write', 'multiedit', 'apply_patch']); +const TOOL_STATUS_PHRASES: Record = { + read: 'reading file', + write: 'writing file', + edit: 'editing file', + multiedit: 'editing files', + apply_patch: 'applying patch', + bash: 'running command', + grep: 'searching content', + glob: 'finding files', + list: 'listing directory', + task: 'delegating task', + webfetch: 'fetching URL', + websearch: 'searching web', + codesearch: 'web code search', + todowrite: 'updating todos', + todoread: 'reading todos', + skill: 'learning skill', + question: 'asking question', + plan_enter: 'switching to planning', + plan_exit: 'switching to building', +}; +const WORKING_PHRASES = [ + 'working', + 'processing', + 'preparing', + 'warming up', + 'gears turning', + 'computing', + 'calculating', + 'analyzing', + 'wheels spinning', + 'calibrating', + 'synthesizing', + 'connecting dots', + 'inspecting logic', + 'weighing options', +]; + +type ParsedStatusResult = { + activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined; + activeToolName: string | undefined; + statusText: string; + isGenericStatus: boolean; +}; + +const getToolStatusPhrase = (toolName: string): string => { + return TOOL_STATUS_PHRASES[toolName] ?? `using ${toolName}`; +}; + +const hashString = (value: string): number => { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + return Math.abs(hash); +}; + +const getStableWorkingPhrase = (key: string): string => { + return WORKING_PHRASES[hashString(key) % WORKING_PHRASES.length] ?? 'working'; +}; + +const createParsedStatus = (parts: Part[], genericKey: string): ParsedStatusResult => { + let activePartType: ParsedStatusResult['activePartType'] = undefined; + let activeToolName: string | undefined = undefined; + + if (!isFullySyntheticMessage(parts)) { + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (!part) continue; + + switch (part.type) { + case 'reasoning': { + const time = part.time ?? getPartTimeInfo(part); + const stillRunning = !time || typeof time.end === 'undefined'; + if (stillRunning && !activePartType) { + activePartType = 'reasoning'; + } + break; + } + case 'tool': { + const toolStatus = part.state?.status; + if ((toolStatus === 'running' || toolStatus === 'pending') && !activePartType) { + const toolName = getToolDisplayName(part); + if (EDITING_TOOLS.has(toolName)) { + activePartType = 'editing'; + activeToolName = toolName; + } else { + activePartType = 'tool'; + activeToolName = toolName; + } + } + break; + } + case 'text': { + const rawContent = getLegacyTextContent(part) ?? ''; + if (typeof rawContent === 'string' && rawContent.trim().length > 0) { + const time = getPartTimeInfo(part); + const streamingPart = !time || typeof time.end === 'undefined'; + if (streamingPart && !activePartType) { + activePartType = 'text'; + } + } + break; + } + default: + break; + } + } + } + + const isGenericStatus = activePartType === undefined; + const statusText = (() => { + if (activePartType === 'editing') return activeToolName === 'multiedit' ? getToolStatusPhrase(activeToolName) : 'editing file'; + if (activePartType === 'tool' && activeToolName) return getToolStatusPhrase(activeToolName); + if (activePartType === 'reasoning') return 'thinking'; + if (activePartType === 'text') return 'composing'; + return getStableWorkingPhrase(genericKey); + })(); + + return { activePartType, activeToolName, statusText, isGenericStatus }; +}; + +const encodeParsedStatus = (status: ParsedStatusResult): string => { + return [ + status.activePartType ?? '', + status.activeToolName ?? '', + status.statusText, + status.isGenericStatus ? '1' : '0', + ].join(STATUS_SIGNATURE_SEPARATOR); +}; + +const decodeParsedStatus = (signature: string): ParsedStatusResult => { + const [activePartType, activeToolName, statusText = 'working', isGenericStatus] = signature.split(STATUS_SIGNATURE_SEPARATOR); + return { + activePartType: activePartType === 'text' || activePartType === 'tool' || activePartType === 'reasoning' || activePartType === 'editing' + ? activePartType + : undefined, + activeToolName: activeToolName || undefined, + statusText, + isGenericStatus: isGenericStatus === '1', + }; +}; const isReasoningPart = (part: Part): part is ReasoningPart => part.type === 'reasoning'; @@ -142,24 +268,12 @@ export function useAssistantStatus(): AssistantStatusSnapshot { return null; }, [rawSessionMessages]); - const lastAssistantParts = useDirectorySync( + const lastAssistantStatusSignature = useDirectorySync( React.useCallback((state) => { - if (!lastAssistantId) return EMPTY_PARTS; - return state.part[lastAssistantId] ?? EMPTY_PARTS; - }, [lastAssistantId]) - ); - - const sessionMessages = React.useMemo( - () => { - if (rawSessionMessages.length === 0) { - return EMPTY_SESSION_MESSAGES; - } - return rawSessionMessages.map((msg) => ({ - info: msg, - parts: msg.id === lastAssistantId ? lastAssistantParts : EMPTY_PARTS, - })); - }, - [lastAssistantParts, rawSessionMessages, lastAssistantId] + const genericKey = `${currentSessionId ?? ''}:${lastAssistantId ?? ''}`; + const parts = lastAssistantId ? (state.part[lastAssistantId] ?? EMPTY_PARTS) : EMPTY_PARTS; + return encodeParsedStatus(createParsedStatus(parts, genericKey)); + }, [currentSessionId, lastAssistantId]) ); const sessionPermissionRequests = useSessionPermissions(currentSessionId ?? ''); @@ -186,148 +300,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot { ? (currentSessionStatus as { type: 'retry'; next?: number }).next : undefined; - type ParsedStatusResult = { - activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined; - activeToolName: string | undefined; - statusText: string; - isGenericStatus: boolean; - }; - const parsedStatus = React.useMemo(() => { - if (sessionMessages.length === 0) { - return { activePartType: undefined, activeToolName: undefined, statusText: 'working', isGenericStatus: true }; - } - - // Pick the latest assistant message by created-time (tiebreak by id) in a - // single pass — sync reconciliation can splice messages out of array order. - const isLater = (a: AssistantSessionMessageRecord, b: AssistantSessionMessageRecord) => { - const aTime = typeof a.info.time?.created === 'number' ? a.info.time.created : null; - const bTime = typeof b.info.time?.created === 'number' ? b.info.time.created : null; - if (aTime !== null && bTime !== null && aTime !== bTime) return aTime > bTime; - return a.info.id.localeCompare(b.info.id) > 0; - }; - - let lastAssistant: AssistantSessionMessageRecord | null = null; - for (const candidate of sessionMessages) { - if (!isAssistantMessage(candidate.info) || isFullySyntheticMessage(candidate.parts)) { - continue; - } - const typed = candidate as AssistantSessionMessageRecord; - if (lastAssistant === null || isLater(typed, lastAssistant)) { - lastAssistant = typed; - } - } - - if (!lastAssistant) { - return { activePartType: undefined, activeToolName: undefined, statusText: 'working', isGenericStatus: true }; - } - - let activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined = undefined; - let activeToolName: string | undefined = undefined; - - const editingTools = new Set(['edit', 'write', 'multiedit', 'apply_patch']); - - for (let i = (lastAssistant.parts ?? []).length - 1; i >= 0; i -= 1) { - const part = lastAssistant.parts?.[i]; - if (!part) continue; - - switch (part.type) { - case 'reasoning': { - const time = part.time ?? getPartTimeInfo(part); - const stillRunning = !time || typeof time.end === 'undefined'; - if (stillRunning && !activePartType) { - activePartType = 'reasoning'; - } - break; - } - case 'tool': { - const toolStatus = part.state?.status; - if ((toolStatus === 'running' || toolStatus === 'pending') && !activePartType) { - const toolName = getToolDisplayName(part); - if (editingTools.has(toolName)) { - activePartType = 'editing'; - activeToolName = toolName; - } else { - activePartType = 'tool'; - activeToolName = toolName; - } - } - break; - } - case 'text': { - const rawContent = getLegacyTextContent(part) ?? ''; - if (typeof rawContent === 'string' && rawContent.trim().length > 0) { - const time = getPartTimeInfo(part); - const streamingPart = !time || typeof time.end === 'undefined'; - if (streamingPart && !activePartType) { - activePartType = 'text'; - } - } - break; - } - default: - break; - } - } - - const TOOL_STATUS_PHRASES: Record = { - read: 'reading file', - write: 'writing file', - edit: 'editing file', - multiedit: 'editing files', - apply_patch: 'applying patch', - bash: 'running command', - grep: 'searching content', - glob: 'finding files', - list: 'listing directory', - task: 'delegating task', - webfetch: 'fetching URL', - websearch: 'searching web', - codesearch: 'web code search', - todowrite: 'updating todos', - todoread: 'reading todos', - skill: 'learning skill', - question: 'asking question', - plan_enter: 'switching to planning', - plan_exit: 'switching to building', - }; - - const WORKING_PHRASES = [ - 'working', - 'processing', - 'preparing', - 'warming up', - 'gears turning', - 'computing', - 'calculating', - 'analyzing', - 'wheels spinning', - 'calibrating', - 'synthesizing', - 'connecting dots', - 'inspecting logic', - 'weighing options', - ]; - - const getToolStatusPhrase = (toolName: string): string => { - return TOOL_STATUS_PHRASES[toolName] ?? `using ${toolName}`; - }; - - const getRandomWorkingPhrase = (): string => { - return WORKING_PHRASES[Math.floor(Math.random() * WORKING_PHRASES.length)]; - }; - - const isGenericStatus = activePartType === undefined; - const statusText = (() => { - if (activePartType === 'editing') return activeToolName === 'multiedit' ? getToolStatusPhrase(activeToolName) : 'editing file'; - if (activePartType === 'tool' && activeToolName) return getToolStatusPhrase(activeToolName); - if (activePartType === 'reasoning') return 'thinking'; - if (activePartType === 'text') return 'composing'; - return getRandomWorkingPhrase(); - })(); - - return { activePartType, activeToolName, statusText, isGenericStatus }; - }, [sessionMessages]); + return decodeParsedStatus(lastAssistantStatusSignature); + }, [lastAssistantStatusSignature]); const abortState = React.useMemo(() => { const hasActiveAbort = Boolean(sessionAbortRecord && !sessionAbortRecord.acknowledged); @@ -394,35 +369,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot { }, [activityPhase, isPhaseWorking, parsedStatus, abortState, sessionRetryAttempt, sessionRetryNext]); const forming = React.useMemo(() => { - const isActive = isPhaseWorking && parsedStatus.activePartType === 'text'; - - if (!isActive || sessionMessages.length === 0) { - return { isActive, characterCount: 0 }; - } - - const assistantMessages = sessionMessages.filter( - (msg): msg is AssistantSessionMessageRecord => - isAssistantMessage(msg.info) && !isFullySyntheticMessage(msg.parts) - ); - - if (assistantMessages.length === 0) { - return { isActive, characterCount: 0 }; - } - - const lastAssistant = assistantMessages[assistantMessages.length - 1]; - let characterCount = 0; - - (lastAssistant.parts ?? []).forEach((part) => { - if (part.type !== 'text') return; - const rawContent = getLegacyTextContent(part) ?? ''; - if (typeof rawContent === 'string' && rawContent.trim().length > 0) { - characterCount += rawContent.length; - } - }); - - return { isActive, characterCount }; - }, [sessionMessages, isPhaseWorking, parsedStatus.activePartType]); + return { isActive, characterCount: 0 }; + }, [isPhaseWorking, parsedStatus.activePartType]); const working = React.useMemo(() => { if (baseWorking.wasAborted || baseWorking.abortActive) {