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,
|
||||
|
||||
Reference in New Issue
Block a user