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:
jwcrystal
2026-04-03 00:29:49 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent aef7b206ed
commit e63450ae2c
6 changed files with 595 additions and 22 deletions
@@ -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,
@@ -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;
}
+10 -7
View File
@@ -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 {
+26 -4
View File
@@ -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()
+167 -3
View File
@@ -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])
}