fix(chat): make task session rendering authoritative

Use the Task part's state.metadata.sessionId as the only live identity contract for child sessions. Remove timestamp, status, and ordering heuristics that could misassociate concurrent subagents or repeatedly scan directory sessions while parts stream.

Extract Task metadata parsing and child-summary projection into a focused model with cached projections for unchanged message records. Preserve output and part-level metadata parsing only for legacy persisted records, and keep standalone Task rows visible when sorted activity groups are rendered.

Validated with focused Task and turn projection tests, UI type-check, lint, and dead-code analysis.
This commit is contained in:
Bohdan Triapitsyn
2026-07-14 01:36:17 +03:00
parent a1badccddd
commit cd1816f63b
7 changed files with 195 additions and 627 deletions
@@ -11,7 +11,7 @@ import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
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 { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
import { useUIStore } from '@/stores/useUIStore';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
@@ -24,7 +24,6 @@ import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { MessageRecord } from '@/lib/messageCompletion';
import {
formatEditOutput,
@@ -42,8 +41,15 @@ import { MinDurationShineText } from './MinDurationShineText';
import { ToolRevealOnMount } from './ToolRevealOnMount';
import { getToolIcon } from './toolPresentation';
import { useDurationTickerNow } from './useDurationTicker';
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser';
import {
buildTaskSummaryEntriesFromSession,
normalizeTaskSummaryEntries,
parseTaskMetadataBlock,
readTaskSessionIdFromOutput,
readTaskSessionIdFromRecord,
stripTaskMetadataFromOutput,
type TaskToolSummaryEntry,
} from './taskToolModel';
import { areRenderRelevantPartsEqual } from '../renderCompare';
import { useI18n } from '@/lib/i18n';
import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils';
@@ -164,7 +170,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
};
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
const TASK_TOOL_FALLBACK_RETRY_MS = 3000;
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
'edit',
@@ -965,94 +970,6 @@ const ToolScrollableTextOutput: React.FC<{
ToolScrollableTextOutput.displayName = 'ToolScrollableTextOutput';
type TaskToolSummaryEntry = {
id?: string;
tool?: string;
state?: {
status?: string;
title?: string;
input?: Record<string, unknown>;
};
};
type SessionMessageWithParts = MessageRecord;
const normalizeSessionIdCandidate = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const readTaskSessionIdFromRecord = (value: unknown): string | undefined => {
if (!value || typeof value !== 'object') {
return undefined;
}
const record = value as Record<string, unknown>;
return (
normalizeSessionIdCandidate(record.sessionID)
?? normalizeSessionIdCandidate(record.sessionId)
);
};
const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => {
if (typeof output !== 'string' || output.trim().length === 0) {
return undefined;
}
const parsedMetadata = parseTaskMetadataBlock(output);
if (parsedMetadata.sessionId) {
return parsedMetadata.sessionId;
}
const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i);
const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i);
const candidate = taskMatch?.[1] ?? sessionMatch?.[1];
if (candidate) {
return normalizeSessionIdCandidate(candidate);
}
// OpenCode tool output may wrap child session id in <task id="ses_xxx">
const taskTagSessionId = readTaskTagSessionIdFromOutput(output);
if (taskTagSessionId) {
return normalizeSessionIdCandidate(taskTagSessionId);
}
return undefined;
};
const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => {
const entries: TaskToolSummaryEntry[] = [];
for (const message of messages) {
if (message?.info?.role !== 'assistant') {
continue;
}
const parts = Array.isArray(message.parts) ? message.parts : [];
for (const part of parts) {
if (part?.type !== 'tool') {
continue;
}
const toolName = normalizeToolName(part.tool);
if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') {
continue;
}
const partState = part.state as { status?: string; title?: string; input?: unknown } | undefined;
entries.push({
id: part.id,
tool: part.tool,
state: {
status: partState?.status,
title: partState?.title,
input: partState?.input && typeof partState.input === 'object'
? (partState.input as Record<string, unknown>)
: undefined,
},
});
}
}
return entries;
};
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
const title = entry.state?.title;
@@ -1249,105 +1166,6 @@ const TaskSummaryEntriesList = React.memo(({
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();
};
const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => {
if (!Array.isArray(value)) {
return [];
}
const normalized: TaskToolSummaryEntry[] = [];
for (const entry of value) {
if (typeof entry === 'string') {
normalized.push({
tool: 'tool',
state: { status: 'completed', title: entry },
});
continue;
}
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
tool?: unknown;
title?: unknown;
status?: unknown;
state?: { status?: unknown; title?: unknown; input?: unknown };
};
const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined;
const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined;
const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined);
const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined);
normalized.push({
id: typeof record.id === 'string' ? record.id : undefined,
tool: typeof record.tool === 'string' ? record.tool : 'tool',
state: {
status,
title,
input: record.state?.input && typeof record.state.input === 'object'
? (record.state.input as Record<string, unknown>)
: undefined,
},
});
}
return normalized;
};
const parseTaskMetadataBlock = (output: string | undefined): {
sessionId?: string;
summaryEntries: TaskToolSummaryEntry[];
} => {
if (typeof output !== 'string' || output.trim().length === 0) {
return { summaryEntries: [] };
}
const blockMatch = output.match(/<task_metadata>\s*([\s\S]*?)\s*<\/task_metadata>/i);
if (!blockMatch?.[1]) {
return { summaryEntries: [] };
}
const raw = blockMatch[1].trim();
if (!raw) {
return { summaryEntries: [] };
}
try {
const parsed = JSON.parse(raw) as {
sessionId?: unknown;
sessionID?: unknown;
summary?: unknown;
entries?: unknown;
tools?: unknown;
calls?: unknown;
};
const summaryEntries = normalizeTaskSummaryEntries(
parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls
);
const sessionId =
(typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0
? parsed.sessionId.trim()
: undefined) ??
(typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0
? parsed.sessionID.trim()
: undefined);
return { sessionId, summaryEntries };
} catch {
return { summaryEntries: [] };
}
};
const TaskToolSummary: React.FC<{
entries: TaskToolSummaryEntry[];
isExpanded: boolean;
@@ -2126,7 +1944,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const state = part.state;
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
const currentDirectory = useEffectiveDirectory() ?? '';
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const normalizedPartTool = normalizeToolName(part.tool);
const isTaskTool = normalizedPartTool === 'task';
@@ -2258,16 +2075,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return Math.min(...candidates);
}, [localStartAt, pinnedTime.start, time?.start]);
const taskSessionResolutionStart = React.useMemo(() => {
if (typeof pinnedTime.start === 'number') {
return pinnedTime.start;
}
if (typeof time?.start === 'number') {
return time.start;
}
return localStartAt;
}, [localStartAt, pinnedTime.start, time?.start]);
const taskOutputString = React.useMemo(() => {
return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
}, [stateWithData.output]);
@@ -2276,10 +2083,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return parseTaskMetadataBlock(taskOutputString);
}, [taskOutputString]);
// Track whether fallback session resolution has failed at least once.
// When true, resolveFallbackTaskSessionId widens its time window (3s → 8s).
const [taskFallbackRetried, setTaskFallbackRetried] = React.useState(false);
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
if (!isTaskTool) {
return [];
@@ -2298,11 +2101,13 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const hasFinalMetadataTaskSummary = isFinalized && metadataTaskSummaryEntries.length > 0;
const explicitTaskSessionId = React.useMemo<string | undefined>(() => {
const taskSessionId = React.useMemo<string | undefined>(() => {
if (!isTaskTool) {
return undefined;
}
// Current OpenCode publishes this authoritative join while the Task is
// running. The remaining sources only support older persisted parts.
const metadataSessionId = readTaskSessionIdFromRecord(metadata);
if (metadataSessionId) {
return metadataSessionId;
@@ -2319,25 +2124,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return readTaskSessionIdFromOutput(taskOutputString);
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]);
const fallbackTaskSessionId = useDirectorySync(
React.useCallback((storeState) => {
if (explicitTaskSessionId) {
return undefined;
}
return resolveFallbackTaskSessionId({
isTaskTool,
parentSessionId: currentSessionId ?? undefined,
taskStartTime: taskSessionResolutionStart,
sessions: storeState.session,
sessionStatusMap: storeState.session_status,
hasRetried: taskFallbackRetried,
});
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, taskFallbackRetried]),
currentDirectory,
);
const taskSessionId = explicitTaskSessionId ?? fallbackTaskSessionId;
const childSessionLookupId = hasFinalMetadataTaskSummary ? '' : (taskSessionId ?? '');
const childSessionMessages = useSessionMessageRecords(childSessionLookupId, currentDirectory);
@@ -2353,43 +2139,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return buildTaskSummaryEntriesFromSession(childSessionMessages);
}, [childSessionMessages, isTaskTool, taskSessionId]);
React.useEffect(() => {
setTaskFallbackRetried(false);
}, [taskSessionId]);
// Widen fallback resolution window only after a real retry boundary.
React.useEffect(() => {
if (!isTaskTool || taskFallbackRetried || explicitTaskSessionId != null || taskSessionId != null || isFinalized) {
return;
}
const sinceStart =
typeof taskSessionResolutionStart === 'number'
? Date.now() - taskSessionResolutionStart
: 0;
const delay = Math.max(0, TASK_TOOL_FALLBACK_RETRY_MS - sinceStart);
if (typeof window === 'undefined') {
setTaskFallbackRetried(true);
return;
}
const timer = window.setTimeout(() => {
setTaskFallbackRetried(true);
}, delay);
return () => {
window.clearTimeout(timer);
};
}, [
explicitTaskSessionId,
isFinalized,
isTaskTool,
taskFallbackRetried,
taskSessionId,
taskSessionResolutionStart,
]);
React.useEffect(() => {
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
setLocalFinalizedAt(undefined);