perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
@@ -12,11 +12,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
|
||||
import { getSyncChildStores } from '@/sync/sync-refs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -164,17 +160,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
|
||||
};
|
||||
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
|
||||
const TASK_TOOL_POLL_FAST_MS = 1200;
|
||||
const TASK_TOOL_POLL_IDLE_MS = 3200;
|
||||
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
|
||||
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
|
||||
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
|
||||
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
|
||||
const VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT = 30;
|
||||
const VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT = 30;
|
||||
const VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT = 30;
|
||||
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
|
||||
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
|
||||
const TASK_TOOL_FALLBACK_RETRY_MS = 3000;
|
||||
const GIT_REFRESH_MUTATING_TOOLS = new Set([
|
||||
'bash',
|
||||
@@ -1025,41 +1010,6 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
|
||||
return entries;
|
||||
};
|
||||
|
||||
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
|
||||
const lastMessageUpdated =
|
||||
typeof lastMessage?.info?.time?.completed === 'number'
|
||||
? lastMessage.info.time.completed
|
||||
: typeof lastMessage?.info?.time?.created === 'number'
|
||||
? lastMessage.info.time.created
|
||||
: 0;
|
||||
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
|
||||
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
|
||||
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
|
||||
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
|
||||
const tailTextLength = (() => {
|
||||
const textCandidate = lastPart?.text;
|
||||
if (typeof textCandidate === 'string') {
|
||||
return textCandidate.length;
|
||||
}
|
||||
const stateCandidate = lastPart?.state;
|
||||
if (stateCandidate && typeof stateCandidate === 'object') {
|
||||
const stateStatus = (stateCandidate as Record<string, unknown>).status;
|
||||
if (typeof stateStatus === 'string') {
|
||||
return stateStatus.length;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
})();
|
||||
|
||||
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
|
||||
};
|
||||
|
||||
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
|
||||
const title = entry.state?.title;
|
||||
if (typeof title === 'string' && title.trim().length > 0) {
|
||||
@@ -1117,6 +1067,144 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
|
||||
return /^[A-Za-z0-9_-]+$/.test(baseName);
|
||||
};
|
||||
|
||||
const getTaskSummaryEntryRenderSignature = (entry: TaskToolSummaryEntry): string => {
|
||||
const toolName = normalizeToolName(entry.tool);
|
||||
const status = entry.state?.status ?? '';
|
||||
const label = getTaskSummaryLabel(entry);
|
||||
return `${entry.id ?? ''}\u0001${toolName}\u0001${status}\u0001${label}`;
|
||||
};
|
||||
|
||||
const areTaskSummaryEntriesRenderEqual = (
|
||||
prevEntries: TaskToolSummaryEntry[],
|
||||
nextEntries: TaskToolSummaryEntry[],
|
||||
): boolean => {
|
||||
if (prevEntries === nextEntries) return true;
|
||||
if (prevEntries.length !== nextEntries.length) return false;
|
||||
for (let index = 0; index < prevEntries.length; index += 1) {
|
||||
if (getTaskSummaryEntryRenderSignature(prevEntries[index]) !== getTaskSummaryEntryRenderSignature(nextEntries[index])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const TaskSummaryEntryRow = React.memo(({
|
||||
entry,
|
||||
isMobile,
|
||||
animateTailText,
|
||||
showToolFileIcons,
|
||||
}: {
|
||||
entry: TaskToolSummaryEntry;
|
||||
isMobile: boolean;
|
||||
animateTailText: boolean;
|
||||
showToolFileIcons: boolean;
|
||||
}) => {
|
||||
const normalizedToolName = normalizeToolName(entry.tool);
|
||||
const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool';
|
||||
const label = getTaskSummaryLabel(entry);
|
||||
const hasLabel = label.trim().length > 0;
|
||||
const status = entry.state?.status;
|
||||
const displayName = getToolMetadata(toolName).displayName;
|
||||
|
||||
return (
|
||||
<ToolRevealOnMount animate={animateTailText} wipe>
|
||||
<div className={cn('flex gap-2 min-w-0 w-full', isMobile ? 'items-start' : 'items-center')}>
|
||||
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
|
||||
<span
|
||||
className="typography-meta text-foreground/80 flex-shrink-0"
|
||||
style={{ color: 'var(--tools-title)' }}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
{hasLabel ? (
|
||||
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
|
||||
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
|
||||
) : (
|
||||
status === 'error' ? (
|
||||
<span className={cn(
|
||||
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
|
||||
isMobile ? 'whitespace-normal break-words' : 'truncate',
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<Text
|
||||
variant={animateTailText ? 'generate-effect' : 'static'}
|
||||
className={cn(
|
||||
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
|
||||
isMobile ? 'whitespace-normal break-words' : 'truncate',
|
||||
)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</ToolRevealOnMount>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return prev.isMobile === next.isMobile
|
||||
&& prev.animateTailText === next.animateTailText
|
||||
&& prev.showToolFileIcons === next.showToolFileIcons
|
||||
&& getTaskSummaryEntryRenderSignature(prev.entry) === getTaskSummaryEntryRenderSignature(next.entry);
|
||||
});
|
||||
|
||||
TaskSummaryEntryRow.displayName = 'TaskSummaryEntryRow';
|
||||
|
||||
const TaskSummaryEntriesList = React.memo(({
|
||||
entries,
|
||||
isExpanded,
|
||||
isMobile,
|
||||
animateTailText,
|
||||
showToolFileIcons,
|
||||
}: {
|
||||
entries: TaskToolSummaryEntry[];
|
||||
isExpanded: boolean;
|
||||
isMobile: boolean;
|
||||
animateTailText: boolean;
|
||||
showToolFileIcons: boolean;
|
||||
}) => {
|
||||
const visibleEntries = isExpanded ? entries : entries.slice(-6);
|
||||
const hiddenCount = Math.max(0, entries.length - visibleEntries.length);
|
||||
const visibleStartIndex = entries.length - visibleEntries.length;
|
||||
|
||||
return (
|
||||
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
|
||||
<div className="w-full min-w-0 space-y-1">
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more…</div>
|
||||
) : null}
|
||||
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const absoluteIndex = isExpanded ? idx : visibleStartIndex + idx;
|
||||
const rowKey = entry.id ?? `${getTaskSummaryEntryRenderSignature(entry)}:${absoluteIndex}`;
|
||||
return (
|
||||
<TaskSummaryEntryRow
|
||||
key={rowKey}
|
||||
entry={entry}
|
||||
isMobile={isMobile}
|
||||
animateTailText={animateTailText}
|
||||
showToolFileIcons={showToolFileIcons}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToolScrollableSection>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return prev.isExpanded === next.isExpanded
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.animateTailText === next.animateTailText
|
||||
&& prev.showToolFileIcons === next.showToolFileIcons
|
||||
&& areTaskSummaryEntriesRenderEqual(prev.entries, next.entries);
|
||||
});
|
||||
|
||||
TaskSummaryEntriesList.displayName = 'TaskSummaryEntriesList';
|
||||
|
||||
const stripTaskMetadataFromOutput = (output: string): string => {
|
||||
// Strip only a trailing <task_metadata>...</task_metadata> block.
|
||||
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
|
||||
@@ -1233,7 +1321,6 @@ const TaskToolSummary: React.FC<{
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const displayEntries = entries;
|
||||
|
||||
const trimmedOutput = typeof output === 'string'
|
||||
? stripTaskMetadataFromOutput(output)
|
||||
@@ -1262,7 +1349,7 @@ const TaskToolSummary: React.FC<{
|
||||
? input.subagent_type
|
||||
: 'subagent';
|
||||
|
||||
if (displayEntries.length === 0 && !hasOutput && !sessionId) {
|
||||
if (entries.length === 0 && !hasOutput && !sessionId) {
|
||||
return (
|
||||
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]">
|
||||
<div className="typography-meta text-muted-foreground/70">
|
||||
@@ -1272,9 +1359,6 @@ const TaskToolSummary: React.FC<{
|
||||
);
|
||||
}
|
||||
|
||||
const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6);
|
||||
const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1283,65 +1367,14 @@ const TaskToolSummary: React.FC<{
|
||||
'before:top-[-0.25rem] before:bottom-0'
|
||||
)}
|
||||
>
|
||||
{displayEntries.length > 0 ? (
|
||||
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
|
||||
<div className="w-full min-w-0 space-y-1">
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more…</div>
|
||||
) : null}
|
||||
|
||||
{visibleEntries.map((entry, idx) => {
|
||||
const normalizedToolName = normalizeToolName(entry.tool);
|
||||
const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool';
|
||||
const label = getTaskSummaryLabel(entry);
|
||||
const hasLabel = label.trim().length > 0;
|
||||
const status = entry.state?.status;
|
||||
|
||||
const displayName = getToolMetadata(toolName).displayName;
|
||||
|
||||
return (
|
||||
<ToolRevealOnMount key={entry.id ?? `${toolName}-${idx}`} animate={animateTailText} wipe>
|
||||
<div className={cn("flex gap-2 min-w-0 w-full", isMobile ? 'items-start' : 'items-center')}>
|
||||
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
|
||||
<span
|
||||
className="typography-meta text-foreground/80 flex-shrink-0"
|
||||
style={{ color: 'var(--tools-title)' }}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
{hasLabel ? (
|
||||
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
|
||||
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
|
||||
) : (
|
||||
status === 'error' ? (
|
||||
<span className={cn(
|
||||
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
|
||||
isMobile ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}>
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<Text
|
||||
variant={animateTailText ? 'generate-effect' : 'static'}
|
||||
className={cn(
|
||||
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
|
||||
isMobile ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
style={{ color: 'var(--tools-description)' }}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</ToolRevealOnMount>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToolScrollableSection>
|
||||
{entries.length > 0 ? (
|
||||
<TaskSummaryEntriesList
|
||||
entries={entries}
|
||||
isExpanded={isExpanded}
|
||||
isMobile={isMobile}
|
||||
animateTailText={animateTailText}
|
||||
showToolFileIcons={showToolFileIcons}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sessionId && (
|
||||
@@ -1357,7 +1390,7 @@ const TaskToolSummary: React.FC<{
|
||||
)}
|
||||
|
||||
{hasOutput ? (
|
||||
<div className={cn('space-y-1', (displayEntries.length > 0 || sessionId) && 'pt-1')}
|
||||
<div className={cn('space-y-1', (entries.length > 0 || sessionId) && 'pt-1')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2147,12 +2180,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
isTaskTool,
|
||||
parentSessionId: currentSessionId ?? undefined,
|
||||
taskStartTime: taskSessionResolutionStart,
|
||||
isTaskFinalized: isFinalized,
|
||||
sessions: storeState.session,
|
||||
sessionStatusMap: storeState.session_status,
|
||||
hasRetried: taskFallbackRetried,
|
||||
});
|
||||
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized, taskFallbackRetried]),
|
||||
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, taskFallbackRetried]),
|
||||
currentDirectory,
|
||||
);
|
||||
|
||||
@@ -2172,49 +2204,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
return buildTaskSummaryEntriesFromSession(childSessionMessages);
|
||||
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||
|
||||
const childSessionHasInFlightTools = React.useMemo(() => {
|
||||
if (!isTaskTool || !taskSessionId || !Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const message of childSessionMessages) {
|
||||
if (message?.info?.role !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
const parts = Array.isArray(message.parts) ? message.parts : [];
|
||||
for (const childPart of parts) {
|
||||
if (childPart?.type !== 'tool') {
|
||||
continue;
|
||||
}
|
||||
const childStatus =
|
||||
typeof childPart === 'object' && childPart !== null && 'state' in childPart
|
||||
? (childPart.state as { status?: string } | undefined)?.status
|
||||
: undefined;
|
||||
if (childStatus === 'running' || childStatus === 'pending' || childStatus === 'started') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||
|
||||
const childSessionActivity = useSessionActivity(taskSessionId, currentDirectory);
|
||||
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
|
||||
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
|
||||
const [taskPendingFinalFetch, setTaskPendingFinalFetch] = React.useState(false);
|
||||
|
||||
const taskPollNoChangeCountRef = React.useRef(0);
|
||||
const taskPollLastSignatureRef = React.useRef<string>('');
|
||||
const taskFinalFetchDoneRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTaskChildSeenActive(false);
|
||||
setTaskChildPollingStopped(false);
|
||||
setTaskPendingFinalFetch(false);
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
taskPollLastSignatureRef.current = '';
|
||||
taskFinalFetchDoneRef.current = false;
|
||||
setTaskFallbackRetried(false);
|
||||
}, [taskSessionId]);
|
||||
|
||||
@@ -2251,148 +2241,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
taskSessionResolutionStart,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childSessionIsActive =
|
||||
childSessionActivity.phase === 'busy'
|
||||
|| childSessionActivity.phase === 'retry'
|
||||
|| childSessionHasInFlightTools;
|
||||
|
||||
if (childSessionIsActive) {
|
||||
if (!taskChildSeenActive) {
|
||||
setTaskChildSeenActive(true);
|
||||
}
|
||||
if (taskChildPollingStopped) {
|
||||
setTaskChildPollingStopped(false);
|
||||
}
|
||||
if (taskPendingFinalFetch) {
|
||||
setTaskPendingFinalFetch(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Always stop polling if already done.
|
||||
if (taskChildPollingStopped && taskFinalFetchDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal settle path: child went idle after we saw it active, and we have entries.
|
||||
// Schedule a grace period before marking polling as stopped.
|
||||
if (taskChildSeenActive && childSessionTaskSummaryEntries.length > 0 && !taskChildPollingStopped) {
|
||||
if (typeof window === 'undefined') {
|
||||
setTaskChildPollingStopped(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setTaskChildPollingStopped(true);
|
||||
}, TASK_TOOL_SETTLE_GRACE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
|
||||
// Final-fetch path: child went idle before parent saw it active, or we have no
|
||||
// entries yet. First stop polling after the settle grace period. A separate
|
||||
// effect performs the final fetch once polling has fully stopped, avoiding
|
||||
// races with any in-flight polling response.
|
||||
if (!taskChildPollingStopped && !taskFinalFetchDoneRef.current) {
|
||||
if (typeof window === 'undefined') {
|
||||
setTaskPendingFinalFetch(true);
|
||||
setTaskChildPollingStopped(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setTaskPendingFinalFetch(true);
|
||||
setTaskChildPollingStopped(true);
|
||||
}, TASK_TOOL_SETTLE_GRACE_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
currentDirectory,
|
||||
hasFinalMetadataTaskSummary,
|
||||
activeLatched,
|
||||
isFinalized,
|
||||
isTaskTool,
|
||||
taskPendingFinalFetch,
|
||||
taskChildPollingStopped,
|
||||
taskChildSeenActive,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const capturedSessionId = taskSessionId;
|
||||
|
||||
const runFinalFetch = async () => {
|
||||
try {
|
||||
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
|
||||
const response = await scopedClient.session.messages({
|
||||
sessionID: capturedSessionId,
|
||||
limit: isVSCodeRuntime() ? VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT : TASK_TOOL_INITIAL_FETCH_LIMIT,
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = response.data ?? [];
|
||||
if (Array.isArray(messages) && messages.length > 0) {
|
||||
const childStores = getSyncChildStores();
|
||||
childStores.update(currentDirectory, (prev) => {
|
||||
const records = messages as SessionMessageWithParts[];
|
||||
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
|
||||
for (const rec of records) {
|
||||
partPatch[rec.info.id] = rec.parts;
|
||||
}
|
||||
return {
|
||||
message: { ...prev.message, [capturedSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
|
||||
part: partPatch,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
taskFinalFetchDoneRef.current = true;
|
||||
setTaskPendingFinalFetch(false);
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTaskPendingFinalFetch(false);
|
||||
setTaskChildPollingStopped(false);
|
||||
}
|
||||
};
|
||||
|
||||
void runFinalFetch();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
currentDirectory,
|
||||
hasFinalMetadataTaskSummary,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
taskPendingFinalFetch,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
|
||||
setLocalFinalizedAt(undefined);
|
||||
@@ -2425,147 +2273,25 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
|
||||
}
|
||||
return metadataTaskSummaryEntries;
|
||||
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
|
||||
const taskSummaryRenderSignature = React.useMemo(() => {
|
||||
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
|
||||
}, [taskSummaryEntries]);
|
||||
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTaskTool || !taskSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
|
||||
if (hasFinalMetadataTaskSummary) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldPoll =
|
||||
!taskChildPollingStopped
|
||||
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
|
||||
const shouldFetchSnapshot = !taskPendingFinalFetch && (childSessionTaskSummaryEntries.length === 0 || shouldPoll);
|
||||
if (!shouldFetchSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let pollTimer: number | undefined;
|
||||
|
||||
const isVisible = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
return document.visibilityState === 'visible';
|
||||
};
|
||||
|
||||
const resolveFetchLimit = (isInitialFetch: boolean) => {
|
||||
if (isVSCodeRuntime()) {
|
||||
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
|
||||
return VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT;
|
||||
}
|
||||
if (isActive || childSessionHasInFlightTools || childSessionActive) {
|
||||
return VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT;
|
||||
}
|
||||
return VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT;
|
||||
}
|
||||
|
||||
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
|
||||
return TASK_TOOL_INITIAL_FETCH_LIMIT;
|
||||
}
|
||||
if (isActive || childSessionHasInFlightTools || childSessionActive) {
|
||||
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
|
||||
}
|
||||
return TASK_TOOL_IDLE_FETCH_LIMIT;
|
||||
};
|
||||
|
||||
const resolvePollDelay = () => {
|
||||
if (!isVisible()) {
|
||||
return TASK_TOOL_POLL_HIDDEN_MS;
|
||||
}
|
||||
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
|
||||
return TASK_TOOL_POLL_IDLE_MS;
|
||||
}
|
||||
return TASK_TOOL_POLL_FAST_MS;
|
||||
};
|
||||
|
||||
const scheduleNextPoll = () => {
|
||||
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
|
||||
return;
|
||||
}
|
||||
pollTimer = window.setTimeout(() => {
|
||||
pollTimer = undefined;
|
||||
void fetchSessionMessages(false);
|
||||
}, resolvePollDelay());
|
||||
};
|
||||
|
||||
const fetchSessionMessages = async (isInitialFetch: boolean) => {
|
||||
try {
|
||||
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
|
||||
const response = await scopedClient.session.messages({
|
||||
sessionID: taskSessionId,
|
||||
limit: resolveFetchLimit(isInitialFetch),
|
||||
});
|
||||
const messages = response.data ?? [];
|
||||
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
|
||||
if (nextSignature === taskPollLastSignatureRef.current) {
|
||||
taskPollNoChangeCountRef.current += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
taskPollLastSignatureRef.current = nextSignature;
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
// Inject fetched subagent messages into sync child store
|
||||
const childStores = getSyncChildStores();
|
||||
childStores.update(currentDirectory, (prev) => {
|
||||
const records = messages as SessionMessageWithParts[];
|
||||
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
|
||||
for (const rec of records) {
|
||||
partPatch[rec.info.id] = rec.parts;
|
||||
}
|
||||
return {
|
||||
message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
|
||||
part: partPatch,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Ignore transient subagent fetch errors.
|
||||
} finally {
|
||||
scheduleNextPoll();
|
||||
}
|
||||
};
|
||||
|
||||
void fetchSessionMessages(true);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (typeof pollTimer === 'number') {
|
||||
window.clearTimeout(pollTimer);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
currentDirectory,
|
||||
hasFinalMetadataTaskSummary,
|
||||
isActive,
|
||||
isTaskTool,
|
||||
taskPendingFinalFetch,
|
||||
taskChildPollingStopped,
|
||||
taskSessionId,
|
||||
]);
|
||||
|
||||
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
|
||||
React.useEffect(() => {
|
||||
if (!isTaskTool) {
|
||||
lastTaskSummaryRenderSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (taskSummaryLenRef.current === taskSummaryEntries.length) {
|
||||
|
||||
const previous = lastTaskSummaryRenderSignatureRef.current;
|
||||
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
|
||||
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
taskSummaryLenRef.current = taskSummaryEntries.length;
|
||||
onContentChange?.('structural');
|
||||
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
|
||||
|
||||
onContentChangeRef.current?.('structural');
|
||||
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
|
||||
|
||||
const diffStats = React.useMemo(() => {
|
||||
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
|
||||
|
||||
+6
-3
@@ -31,13 +31,16 @@ describe('resolveFallbackTaskSessionId', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when task is finalized', () => {
|
||||
it('returns undefined when multiple idle candidates are ambiguous', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
isTaskFinalized: true,
|
||||
sessions: [
|
||||
makeSession({ id: 'child-a', parentID: parentSessionId, time: { created: taskStartTime + 100 } }),
|
||||
makeSession({ id: 'child-b', parentID: parentSessionId, time: { created: taskStartTime + 200 } }),
|
||||
],
|
||||
sessionStatusMap: {},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -29,8 +29,6 @@ export interface ResolveFallbackParams {
|
||||
parentSessionId: string | undefined;
|
||||
/** When the task tool started (ms timestamp) */
|
||||
taskStartTime: number | undefined;
|
||||
/** True when the task tool is finalized (completed/error/etc.) */
|
||||
isTaskFinalized?: boolean;
|
||||
/** Sessions from the directory store */
|
||||
sessions: Session[];
|
||||
/** Session status map from the sync store */
|
||||
@@ -45,8 +43,8 @@ export interface ResolveFallbackParams {
|
||||
*
|
||||
* Returns `undefined` when:
|
||||
* - Not a task tool
|
||||
* - Task is finalized
|
||||
* - Parent session is unknown
|
||||
* - Task start time is unknown
|
||||
* - No unambiguous match found
|
||||
*/
|
||||
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
|
||||
@@ -54,7 +52,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
|
||||
isTaskTool,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
isTaskFinalized = false,
|
||||
sessions,
|
||||
sessionStatusMap,
|
||||
hasRetried = false,
|
||||
@@ -64,6 +61,10 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof taskStartTime !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Filter candidate sessions: parentID matches the current session.
|
||||
let candidates = sessions.filter((session) => {
|
||||
if (!session?.id || session.parentID !== parentSessionId) {
|
||||
@@ -72,19 +73,14 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
|
||||
return true;
|
||||
});
|
||||
|
||||
// When the task is still running, apply no time window — late-appearing
|
||||
// child sessions should still match. Once finalized, restrict to sessions
|
||||
// created within a generous window around the task start to avoid binding
|
||||
// to stale siblings. If taskStartTime is unavailable (cross-OpenCode
|
||||
// sessions), skip the time filter entirely.
|
||||
if (typeof taskStartTime === 'number' && isTaskFinalized) {
|
||||
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
|
||||
const latestAllowed = taskStartTime + windowMs;
|
||||
candidates = candidates.filter((session) => {
|
||||
const created = session.time?.created;
|
||||
return typeof created === 'number' && created >= taskStartTime - 2_000 && created <= latestAllowed;
|
||||
});
|
||||
}
|
||||
// Apply the time window even while running. Without it, a newly rendered task
|
||||
// can briefly bind to the previous child session before its own child exists.
|
||||
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
|
||||
const latestAllowed = taskStartTime + windowMs;
|
||||
candidates = candidates.filter((session) => {
|
||||
const created = session.time?.created;
|
||||
return typeof created === 'number' && created >= taskStartTime && created <= latestAllowed;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
@@ -105,18 +101,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
|
||||
return liveCandidates[0].id;
|
||||
}
|
||||
|
||||
// All idle: pick the most recently created child session.
|
||||
// This handles the common case where a delegation completed and the
|
||||
// user is viewing the task tool result inline.
|
||||
if (liveCandidates.length === 0 && candidates.length > 1) {
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const aCreated = typeof a.time?.created === 'number' ? a.time.created : 0;
|
||||
const bCreated = typeof b.time?.created === 'number' ? b.time.created : 0;
|
||||
return bCreated - aCreated;
|
||||
});
|
||||
return sorted[0].id;
|
||||
}
|
||||
|
||||
// Ambiguous — do not guess
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user