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;
|
||||
}
|
||||
@@ -21,14 +21,14 @@ const IDLE_RESULT: SessionActivityResult = {
|
||||
|
||||
/**
|
||||
* Determines if a session is actively working.
|
||||
* Checks session_status and, as a narrow fallback, only the trailing
|
||||
* assistant message when its completion update has not landed yet.
|
||||
* Checks session_status and, only when status is missing, falls back to the
|
||||
* trailing assistant message when its completion update has not landed yet.
|
||||
* Returns idle when permissions are pending (permission indicator takes priority).
|
||||
*/
|
||||
export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult {
|
||||
const status = useSessionStatus(sessionId ?? '');
|
||||
const messages = useSessionMessages(sessionId ?? '');
|
||||
const permissions = useSessionPermissions(sessionId ?? '');
|
||||
export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
|
||||
const status = useSessionStatus(sessionId ?? '', directory);
|
||||
const messages = useSessionMessages(sessionId ?? '', directory);
|
||||
const permissions = useSessionPermissions(sessionId ?? '', directory);
|
||||
|
||||
return React.useMemo<SessionActivityResult>(() => {
|
||||
if (!sessionId) return IDLE_RESULT;
|
||||
@@ -47,9 +47,12 @@ export function useSessionActivity(sessionId: string | null | undefined): Sessio
|
||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
||||
);
|
||||
|
||||
const statusWorking = phase !== 'idle';
|
||||
const hasAuthoritativeStatus = status !== undefined;
|
||||
const statusWorking = hasAuthoritativeStatus && phase !== 'idle';
|
||||
const isWorking = statusWorking || hasPendingAssistant;
|
||||
|
||||
if (hasAuthoritativeStatus && !statusWorking) return IDLE_RESULT;
|
||||
|
||||
if (!isWorking) return IDLE_RESULT;
|
||||
|
||||
return {
|
||||
|
||||
@@ -34,12 +34,17 @@ const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
// Pipeline factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEventPipeline(input: {
|
||||
export type EventPipelineInput = {
|
||||
sdk: OpencodeClient
|
||||
onEvent: (directory: string, payload: Event) => void
|
||||
}) {
|
||||
const { sdk, onEvent } = input
|
||||
/** Called after SSE reconnects (visibility restore or heartbeat timeout). */
|
||||
onReconnect?: () => void
|
||||
}
|
||||
|
||||
export function createEventPipeline(input: EventPipelineInput) {
|
||||
const { sdk, onEvent, onReconnect } = input
|
||||
const abort = new AbortController()
|
||||
let hasConnected = false
|
||||
|
||||
// Queue state
|
||||
let queue: QueuedEvent[] = []
|
||||
@@ -149,6 +154,12 @@ export function createEventPipeline(input: {
|
||||
},
|
||||
})
|
||||
|
||||
if (hasConnected) {
|
||||
onReconnect?.()
|
||||
} else {
|
||||
hasConnected = true
|
||||
}
|
||||
|
||||
let yielded = Date.now()
|
||||
resetHeartbeat()
|
||||
|
||||
@@ -197,21 +208,32 @@ export function createEventPipeline(input: {
|
||||
}
|
||||
})().finally(flush)
|
||||
|
||||
// Visibility handler — flush immediately when tab becomes visible
|
||||
// Visibility handler — abort SSE on heartbeat timeout so the loop reconnects.
|
||||
// The reconnect triggers onReconnect above, which lets consumers resync state.
|
||||
const onVisibility = () => {
|
||||
if (typeof document === "undefined") return
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
}
|
||||
|
||||
// pageshow handler — fires on back-forward cache restore (common on mobile PWA).
|
||||
// bfcache restores the page without a fresh load, so SSE state may be stale.
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
if (!event.persisted) return
|
||||
attempt?.abort()
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility)
|
||||
window.addEventListener("pageshow", onPageShow)
|
||||
}
|
||||
|
||||
// Cleanup — abort SSE, flush remaining events, remove listeners
|
||||
const cleanup = () => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility)
|
||||
window.removeEventListener("pageshow", onPageShow)
|
||||
}
|
||||
abort.abort()
|
||||
flush()
|
||||
|
||||
@@ -13,6 +13,7 @@ import { retry } from "./retry"
|
||||
import { updateStreamingState } from "./streaming"
|
||||
import { setActionRefs } from "./session-actions"
|
||||
import { setSyncRefs } from "./sync-refs"
|
||||
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { opencodeClient } from "@/lib/opencode/client"
|
||||
import { usePermissionStore } from "@/stores/permissionStore"
|
||||
import { autoRespondsPermission, normalizeDirectory } from "@/stores/utils/permissionAutoAccept"
|
||||
@@ -86,6 +87,10 @@ export function useAllSessionStatuses(): Record<string, SessionStatus> {
|
||||
let bootingRoot = false
|
||||
let bootedAt = 0
|
||||
const BOOT_DEBOUNCE_MS = 1500
|
||||
const RECONNECT_MESSAGE_LIMIT = 200
|
||||
const RECONNECT_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
// Module-level refs for notification viewed check.
|
||||
// Used to determine if user is currently viewing the session when a notification arrives.
|
||||
@@ -107,6 +112,145 @@ function isRecentBoot() {
|
||||
return bootingRoot || Date.now() - bootedAt < BOOT_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
function setGlobalSessionStatuses(nextStatuses: Record<string, SessionStatus>) {
|
||||
const current = useGlobalSessionStatusStore.getState().statuses
|
||||
let changed = false
|
||||
const merged = { ...current }
|
||||
|
||||
for (const [sessionId, status] of Object.entries(nextStatuses)) {
|
||||
if (!status || merged[sessionId] === status) continue
|
||||
merged[sessionId] = status
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
useGlobalSessionStatusStore.setState({ statuses: merged })
|
||||
}
|
||||
}
|
||||
|
||||
function getReconnectCandidateSessionIds(state: State) {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const [sessionId, status] of Object.entries(state.session_status ?? {})) {
|
||||
if (status && status.type !== "idle") ids.add(sessionId)
|
||||
}
|
||||
|
||||
for (const [sessionId, messages] of Object.entries(state.message ?? {})) {
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
if (
|
||||
lastMessage
|
||||
&& lastMessage.role === "assistant"
|
||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== "number"
|
||||
) {
|
||||
ids.add(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
}
|
||||
|
||||
function toSessionStatus(status: Awaited<ReturnType<typeof opencodeClient.getSessionStatus>>[string]): SessionStatus | undefined {
|
||||
if (!status) return undefined
|
||||
if (status.type === "idle" || status.type === "busy") {
|
||||
return { type: status.type }
|
||||
}
|
||||
if (
|
||||
status.type === "retry"
|
||||
&& typeof status.attempt === "number"
|
||||
&& typeof status.message === "string"
|
||||
&& typeof status.next === "number"
|
||||
) {
|
||||
return {
|
||||
type: "retry",
|
||||
attempt: status.attempt,
|
||||
message: status.message,
|
||||
next: status.next,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function resyncDirectoryAfterReconnect(directory: string, store: StoreApi<DirectoryStore>) {
|
||||
const current = store.getState()
|
||||
const candidateSessionIds = getReconnectCandidateSessionIds(current)
|
||||
if (candidateSessionIds.length === 0) return
|
||||
|
||||
const nextStatuses = await opencodeClient.getSessionStatusForDirectory(directory)
|
||||
const relevantStatuses: Record<string, SessionStatus> = {}
|
||||
|
||||
for (const sessionId of candidateSessionIds) {
|
||||
const nextStatus = toSessionStatus(nextStatuses[sessionId])
|
||||
if (nextStatus) {
|
||||
relevantStatuses[sessionId] = nextStatus
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(relevantStatuses).length > 0) {
|
||||
store.setState((state: DirectoryStore) => ({
|
||||
session_status: { ...state.session_status, ...relevantStatuses },
|
||||
}))
|
||||
setGlobalSessionStatuses(relevantStatuses)
|
||||
}
|
||||
|
||||
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||
await Promise.all(candidateSessionIds.map(async (sessionId) => {
|
||||
const [sessionResponse, messageResponse] = await Promise.all([
|
||||
scopedClient.session.get({ sessionID: sessionId }).catch(() => null),
|
||||
scopedClient.session.messages({ sessionID: sessionId, limit: RECONNECT_MESSAGE_LIMIT }).catch(() => null),
|
||||
])
|
||||
const session = sessionResponse?.data
|
||||
const records = messageResponse?.data
|
||||
if (!session || !records) return
|
||||
|
||||
const nextSession = stripSessionDiffSnapshots(session)
|
||||
const nextMessages = records
|
||||
.filter((record) => !!record?.info?.id)
|
||||
.map((record) => stripMessageDiffSnapshots(record.info))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const nextMessageIds = new Set(nextMessages.map((message) => message.id))
|
||||
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const sessions = [...state.session]
|
||||
const sessionIndex = sessions.findIndex((item) => item.id === nextSession.id)
|
||||
let sessionChanged = false
|
||||
let sessionTotal = state.sessionTotal
|
||||
|
||||
if (sessionIndex >= 0) {
|
||||
if (sessions[sessionIndex] !== nextSession) {
|
||||
sessions[sessionIndex] = nextSession
|
||||
sessionChanged = true
|
||||
}
|
||||
} else {
|
||||
sessions.push(nextSession)
|
||||
sessions.sort((a, b) => cmp(a.id, b.id))
|
||||
if (!nextSession.parentID) sessionTotal += 1
|
||||
sessionChanged = true
|
||||
}
|
||||
|
||||
const nextPartState = { ...state.part }
|
||||
const previousMessages = state.message[sessionId] ?? []
|
||||
for (const message of previousMessages) {
|
||||
if (!nextMessageIds.has(message.id)) {
|
||||
delete nextPartState[message.id]
|
||||
}
|
||||
}
|
||||
for (const record of records) {
|
||||
const messageId = record?.info?.id
|
||||
if (!messageId) continue
|
||||
nextPartState[messageId] = (record.parts ?? [])
|
||||
.filter((part) => !!part?.id && !RECONNECT_SKIP_PARTS.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
return {
|
||||
...(sessionChanged ? { session: sessions, sessionTotal } : {}),
|
||||
message: { ...state.message, [sessionId]: nextMessages },
|
||||
part: nextPartState,
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
function handleEvent(
|
||||
rawDirectory: string,
|
||||
payload: Event,
|
||||
@@ -381,11 +525,28 @@ export function SyncProvider(props: {
|
||||
// Event pipeline — created once per mount. No class, no start/stop.
|
||||
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
||||
useEffect(() => {
|
||||
const reconnectResyncing = new Set<string>()
|
||||
|
||||
const { cleanup } = createEventPipeline({
|
||||
sdk: props.sdk,
|
||||
onEvent: (directory, payload) => {
|
||||
handleEvent(directory, payload, childStores)
|
||||
},
|
||||
onReconnect: () => {
|
||||
for (const [dir, store] of childStores.children) {
|
||||
if (reconnectResyncing.has(dir)) continue
|
||||
if (getReconnectCandidateSessionIds(store.getState()).length === 0) continue
|
||||
|
||||
reconnectResyncing.add(dir)
|
||||
void resyncDirectoryAfterReconnect(dir, store)
|
||||
.catch(() => {
|
||||
// Transient failure during resync — next SSE event or reconnect will catch up.
|
||||
})
|
||||
.finally(() => {
|
||||
reconnectResyncing.delete(dir)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
return cleanup
|
||||
}, [props.sdk, childStores])
|
||||
@@ -610,7 +771,8 @@ export function useSessionMessageRecords(sessionID: string, directory?: string)
|
||||
|
||||
/**
|
||||
* Determines if a session is actively working.
|
||||
* Checks session_status AND incomplete assistant messages as fallback.
|
||||
* Checks session_status and only falls back to incomplete assistant messages
|
||||
* when authoritative status is missing.
|
||||
* Returns false when permissions are pending (permission indicator takes priority).
|
||||
*/
|
||||
export function useIsSessionWorking(sessionID: string, directory?: string): boolean {
|
||||
@@ -623,7 +785,8 @@ export function useIsSessionWorking(sessionID: string, directory?: string): bool
|
||||
if (permissions.length > 0) return false
|
||||
|
||||
// Check session_status
|
||||
const statusWorking = status !== undefined && status.type !== "idle"
|
||||
const hasAuthoritativeStatus = status !== undefined
|
||||
const statusWorking = hasAuthoritativeStatus && status.type !== "idle"
|
||||
|
||||
// Check for incomplete assistant message (fallback if status event delayed)
|
||||
let hasPendingAssistant = false
|
||||
@@ -635,7 +798,8 @@ export function useIsSessionWorking(sessionID: string, directory?: string): bool
|
||||
}
|
||||
}
|
||||
|
||||
return statusWorking || hasPendingAssistant
|
||||
if (hasAuthoritativeStatus) return statusWorking
|
||||
return hasPendingAssistant
|
||||
}, [status, permissions, messages])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user