fix: resync session state after SSE reconnect to prevent stuck subagent UI (#817)
* fix: resync session state after SSE reconnect to prevent stuck subagent UI When a subagent completes while the page is in the background (common on mobile PWA and desktop webview), the final SSE events are lost. The UI then stays stuck on 'Waiting for subagent activity...' because: - part.state.status never transitions to 'completed' - session_status is never updated to 'idle' - activeLatched remains true indefinitely Fix: - Add onReconnect callback to event pipeline, fired after SSE reconnect - Add pageshow listener for bfcache restores (mobile PWA back-forward) - On reconnect, re-fetch session list for directories with active sessions - Pass explicit directory to useSessionActivity in ToolPart for subagents to ensure the correct child store is queried Closes #810 * fix(chat): resolve pending subagent task binding before metadata arrives * fix: restore subagent activity and tool visibility after reconnect - Resyncs session status and child session data after SSE reconnect - Ensures child task tool messages are read from the correct directory - Prevents stale assistant fallback from keeping sessions stuck as active --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
aef7b206ed
commit
e63450ae2c
@@ -12,8 +12,8 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { getSyncChildStores, getSyncDirectory } from '@/sync/sync-refs';
|
||||
import { useDirectorySync, useSessionMessageRecords } 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';
|
||||
@@ -38,6 +38,7 @@ import { MinDurationShineText } from './MinDurationShineText';
|
||||
import { ToolRevealOnMount } from './ToolRevealOnMount';
|
||||
import { getToolIcon } from './toolPresentation';
|
||||
import { useDurationTickerNow } from './useDurationTicker';
|
||||
import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId';
|
||||
|
||||
type ToolStateWithMetadata = ToolStateUnion & { metadata?: Record<string, unknown>; input?: Record<string, unknown>; output?: string; error?: string; time?: { start: number; end?: number } };
|
||||
|
||||
@@ -1561,6 +1562,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
const state = part.state;
|
||||
const showToolFileIcons = useUIStore((s) => s.showToolFileIcons);
|
||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
|
||||
const normalizedPartTool = normalizeToolName(part.tool);
|
||||
const isTaskTool = normalizedPartTool === 'task';
|
||||
@@ -1660,6 +1662,16 @@ const ToolPart: 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]);
|
||||
@@ -1668,7 +1680,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return parseTaskMetadataBlock(taskOutputString);
|
||||
}, [taskOutputString]);
|
||||
|
||||
const taskSessionId = React.useMemo<string | undefined>(() => {
|
||||
const explicitTaskSessionId = React.useMemo<string | undefined>(() => {
|
||||
if (!isTaskTool) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1689,7 +1701,27 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return readTaskSessionIdFromOutput(taskOutputString);
|
||||
}, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]);
|
||||
|
||||
const childSessionMessages = useSessionMessageRecords(taskSessionId ?? '');
|
||||
const fallbackTaskSessionId = useDirectorySync(
|
||||
React.useCallback((storeState) => {
|
||||
if (explicitTaskSessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolveFallbackTaskSessionId({
|
||||
isTaskTool,
|
||||
parentSessionId: currentSessionId ?? undefined,
|
||||
taskStartTime: taskSessionResolutionStart,
|
||||
isTaskFinalized: isFinalized,
|
||||
sessions: storeState.session,
|
||||
sessionStatusMap: storeState.session_status,
|
||||
});
|
||||
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized]),
|
||||
currentDirectory,
|
||||
);
|
||||
|
||||
const taskSessionId = explicitTaskSessionId ?? fallbackTaskSessionId;
|
||||
|
||||
const childSessionMessages = useSessionMessageRecords(taskSessionId ?? '', currentDirectory);
|
||||
|
||||
const metadataTaskSummaryEntries = React.useMemo<TaskToolSummaryEntry[]>(() => {
|
||||
if (!isTaskTool) {
|
||||
@@ -1744,7 +1776,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
return false;
|
||||
}, [childSessionMessages, isTaskTool, taskSessionId]);
|
||||
|
||||
const childSessionActivity = useSessionActivity(taskSessionId);
|
||||
const childSessionActivity = useSessionActivity(taskSessionId, currentDirectory);
|
||||
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
|
||||
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
|
||||
|
||||
@@ -1896,7 +1928,12 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
|
||||
const fetchSessionMessages = async (isInitialFetch: boolean) => {
|
||||
try {
|
||||
const messages = await opencodeClient.getSessionMessages(taskSessionId, resolveFetchLimit(isInitialFetch));
|
||||
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;
|
||||
}
|
||||
@@ -1911,8 +1948,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
taskPollNoChangeCountRef.current = 0;
|
||||
// Inject fetched subagent messages into sync child store
|
||||
const childStores = getSyncChildStores();
|
||||
const dir = getSyncDirectory();
|
||||
childStores.update(dir, (prev) => {
|
||||
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) {
|
||||
@@ -1942,6 +1978,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
||||
childSessionActivity.phase,
|
||||
childSessionHasInFlightTools,
|
||||
childSessionTaskSummaryEntries.length,
|
||||
currentDirectory,
|
||||
isActive,
|
||||
isTaskTool,
|
||||
taskChildPollingStopped,
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { resolveFallbackTaskSessionId } from '../resolveFallbackTaskSessionId';
|
||||
|
||||
const busyStatus = { type: 'busy' };
|
||||
const retryStatus = { type: 'retry', attempt: 1, message: '', next: Date.now() + 5000 };
|
||||
|
||||
const makeSession = (overrides) => ({
|
||||
slug: overrides.id,
|
||||
projectID: 'proj',
|
||||
directory: '/test',
|
||||
title: overrides.title ?? `Session ${overrides.id}`,
|
||||
version: '1',
|
||||
time: {
|
||||
created: overrides.time?.created ?? Date.now(),
|
||||
updated: overrides.time?.updated ?? Date.now(),
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('resolveFallbackTaskSessionId', () => {
|
||||
const parentSessionId = 'parent-session-1';
|
||||
const taskStartTime = 1000000;
|
||||
|
||||
it('returns undefined when not a task tool', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: false,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when task is finalized', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
isTaskFinalized: true,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when parentSessionId is missing', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId: undefined,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no sessions exist', () => {
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the child session id when exactly one child matches parent and time', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBe('child-1');
|
||||
});
|
||||
|
||||
it('returns undefined when child was created before task start', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime - 1, updated: taskStartTime - 1 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when child was created too long after task start', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 5000, updated: taskStartTime + 5000 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when multiple children match and are ambiguous', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the busy child when multiple children match but only one is busy', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
sessionStatusMap: {
|
||||
'child-2': busyStatus,
|
||||
},
|
||||
});
|
||||
expect(result).toBe('child-2');
|
||||
});
|
||||
|
||||
it('returns undefined when multiple children are both busy (ambiguous)', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
sessionStatusMap: {
|
||||
'child-1': busyStatus,
|
||||
'child-2': busyStatus,
|
||||
},
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores sessions with different parentID', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: 'other-parent',
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores sessions without parentID', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers exactly one live candidate (retry status) over ambiguous total', () => {
|
||||
const child1 = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 100, updated: taskStartTime + 100 },
|
||||
});
|
||||
const child2 = makeSession({
|
||||
id: 'child-2',
|
||||
parentID: parentSessionId,
|
||||
time: { created: taskStartTime + 200, updated: taskStartTime + 200 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
sessions: [child1, child2],
|
||||
sessionStatusMap: {
|
||||
'child-1': retryStatus,
|
||||
},
|
||||
});
|
||||
expect(result).toBe('child-1');
|
||||
});
|
||||
|
||||
it('returns undefined when taskStartTime is undefined', () => {
|
||||
const child = makeSession({
|
||||
id: 'child-1',
|
||||
parentID: parentSessionId,
|
||||
time: { created: 100, updated: 100 },
|
||||
});
|
||||
|
||||
const result = resolveFallbackTaskSessionId({
|
||||
isTaskTool: true,
|
||||
parentSessionId,
|
||||
taskStartTime: undefined,
|
||||
sessions: [child],
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* resolveFallbackTaskSessionId — pure helper that resolves a pending task tool
|
||||
* to a child session from the directory session store when explicit taskSessionId
|
||||
* metadata is delayed.
|
||||
*
|
||||
* Conservative: only returns a session id when the match is unambiguous.
|
||||
*/
|
||||
|
||||
import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
/**
|
||||
* Fallback is intentionally narrow: only sessions created shortly after the
|
||||
* task started are eligible. This avoids binding to earlier or later sibling
|
||||
* subagent sessions when explicit task metadata is delayed.
|
||||
*/
|
||||
const TASK_SESSION_MATCH_WINDOW_MS = 3000;
|
||||
|
||||
const LIVE_STATUSES = new Set<string>(['busy', 'retry']);
|
||||
|
||||
export interface ResolveFallbackParams {
|
||||
/** True when this tool is a task tool */
|
||||
isTaskTool: boolean;
|
||||
/** The parent session id (current session) */
|
||||
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 */
|
||||
sessionStatusMap?: Record<string, SessionStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to resolve a child session id for a pending task tool by matching
|
||||
* against sessions in the directory store.
|
||||
*
|
||||
* Returns `undefined` when:
|
||||
* - Not a task tool
|
||||
* - Task is finalized
|
||||
* - Parent session is unknown
|
||||
* - No unambiguous match found
|
||||
*/
|
||||
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
|
||||
const {
|
||||
isTaskTool,
|
||||
parentSessionId,
|
||||
taskStartTime,
|
||||
isTaskFinalized = false,
|
||||
sessions,
|
||||
sessionStatusMap,
|
||||
} = params;
|
||||
|
||||
if (!isTaskTool || isTaskFinalized || !parentSessionId || typeof taskStartTime !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const latestAllowed = taskStartTime + TASK_SESSION_MATCH_WINDOW_MS;
|
||||
|
||||
// Filter candidate sessions: parentID matches and created shortly after task start.
|
||||
const candidates = sessions.filter((session) => {
|
||||
if (!session?.id || session.parentID !== parentSessionId) {
|
||||
return false;
|
||||
}
|
||||
const created = session.time?.created;
|
||||
if (typeof created !== 'number') {
|
||||
return false;
|
||||
}
|
||||
return created >= taskStartTime && created <= latestAllowed;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If exactly one candidate, return it regardless of status
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0].id;
|
||||
}
|
||||
|
||||
// Multiple candidates: try to disambiguate by finding exactly one live (busy/retry)
|
||||
const liveCandidates = candidates.filter((session) => {
|
||||
const status = sessionStatusMap?.[session.id];
|
||||
return status != null && LIVE_STATUSES.has(status.type);
|
||||
});
|
||||
|
||||
if (liveCandidates.length === 1) {
|
||||
return liveCandidates[0].id;
|
||||
}
|
||||
|
||||
// Ambiguous — do not guess
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user