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
This commit is contained in:
Bohdan Triapitsyn
2026-05-27 14:19:15 +03:00
parent cfd13544bd
commit 62a5a254b3
2 changed files with 167 additions and 211 deletions
+13 -6
View File
@@ -155,19 +155,26 @@ export const StatusRow: React.FC<StatusRowProps> = ({
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();
+154 -205
View File
@@ -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<string, string> = {
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<SessionMessageRecord[]>(
() => {
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<ParsedStatusResult>(() => {
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<string, string> = {
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<FormingSummary>(() => {
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<WorkingSummary>(() => {
if (baseWorking.wasAborted || baseWorking.abortActive) {