diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 35219d3b..04bead90 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -161,6 +161,7 @@ const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160; const TASK_TOOL_IDLE_FETCH_LIMIT = 80; 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', 'edit', @@ -1880,6 +1881,10 @@ const ToolPart: React.FC = ({ 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 explicitTaskSessionId = React.useMemo(() => { if (!isTaskTool) { return undefined; @@ -1914,8 +1919,9 @@ const ToolPart: React.FC = ({ isTaskFinalized: isFinalized, sessions: storeState.session, sessionStatusMap: storeState.session_status, + hasRetried: taskFallbackRetried, }); - }, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized]), + }, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized, taskFallbackRetried]), currentDirectory, ); @@ -1979,17 +1985,55 @@ const ToolPart: React.FC = ({ 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(''); + const taskFinalFetchDoneRef = React.useRef(false); React.useEffect(() => { setTaskChildSeenActive(false); setTaskChildPollingStopped(false); + setTaskPendingFinalFetch(false); taskPollNoChangeCountRef.current = 0; taskPollLastSignatureRef.current = ''; + taskFinalFetchDoneRef.current = false; + 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 (!isTaskTool || !taskSessionId) { return; @@ -2007,37 +2051,129 @@ const ToolPart: React.FC = ({ if (taskChildPollingStopped) { setTaskChildPollingStopped(false); } + if (taskPendingFinalFetch) { + setTaskPendingFinalFetch(false); + } return; } - if (!taskChildSeenActive || taskChildPollingStopped || childSessionTaskSummaryEntries.length === 0) { + // Always stop polling if already done. + if (taskChildPollingStopped && taskFinalFetchDoneRef.current) { return; } - if (typeof window === 'undefined') { - setTaskChildPollingStopped(true); - 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); + }; } - const timer = window.setTimeout(() => { - setTaskChildPollingStopped(true); - }, TASK_TOOL_SETTLE_GRACE_MS); + // 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; + } - return () => { - window.clearTimeout(timer); - }; + const timer = window.setTimeout(() => { + setTaskPendingFinalFetch(true); + setTaskChildPollingStopped(true); + }, TASK_TOOL_SETTLE_GRACE_MS); + + return () => { + window.clearTimeout(timer); + }; + } }, [ childSessionActivity.phase, childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, + currentDirectory, activeLatched, isFinalized, isTaskTool, + taskPendingFinalFetch, taskChildPollingStopped, taskChildSeenActive, taskSessionId, ]); + React.useEffect(() => { + if (!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: 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 = { ...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, + isTaskTool, + taskChildPollingStopped, + taskPendingFinalFetch, + taskSessionId, + ]); + React.useEffect(() => { if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') { setLocalFinalizedAt(undefined); @@ -2080,7 +2216,7 @@ const ToolPart: React.FC = ({ const shouldPoll = !taskChildPollingStopped && (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0); - const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll; + const shouldFetchSnapshot = !taskPendingFinalFetch && (childSessionTaskSummaryEntries.length === 0 || shouldPoll); if (!shouldFetchSnapshot) { return; } @@ -2180,6 +2316,7 @@ const ToolPart: React.FC = ({ currentDirectory, isActive, isTaskTool, + taskPendingFinalFetch, taskChildPollingStopped, taskSessionId, ]); diff --git a/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts b/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts index b3f6e0d4..436e9ee8 100644 --- a/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts +++ b/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts @@ -13,7 +13,12 @@ import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client'; * task started are eligible. This avoids binding to earlier or later sibling * subagent sessions when explicit task metadata is delayed. */ +/** + * Narrow initial window avoids binding to wrong sessions on first attempt. + * Wide window on retry handles late-appearing child sessions under load. + */ const TASK_SESSION_MATCH_WINDOW_MS = 3000; +const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000; const LIVE_STATUSES = new Set(['busy', 'retry']); @@ -30,6 +35,8 @@ export interface ResolveFallbackParams { sessions: Session[]; /** Session status map from the sync store */ sessionStatusMap?: Record; + /** True when a previous resolution attempt has already failed (enables wider window) */ + hasRetried?: boolean; } /** @@ -50,13 +57,15 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str isTaskFinalized = false, sessions, sessionStatusMap, + hasRetried = false, } = params; if (!isTaskTool || isTaskFinalized || !parentSessionId || typeof taskStartTime !== 'number') { return undefined; } - const latestAllowed = taskStartTime + TASK_SESSION_MATCH_WINDOW_MS; + const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS; + const latestAllowed = taskStartTime + windowMs; // Filter candidate sessions: parentID matches and created shortly after task start. const candidates = sessions.filter((session) => {