diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 89909470..5f894cc3 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -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; input?: Record; output?: string; error?: string; time?: { start: number; end?: number } }; @@ -1561,6 +1562,7 @@ const ToolPart: React.FC = ({ 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 = ({ 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 = ({ return parseTaskMetadataBlock(taskOutputString); }, [taskOutputString]); - const taskSessionId = React.useMemo(() => { + const explicitTaskSessionId = React.useMemo(() => { if (!isTaskTool) { return undefined; } @@ -1689,7 +1701,27 @@ const ToolPart: React.FC = ({ 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(() => { if (!isTaskTool) { @@ -1744,7 +1776,7 @@ const ToolPart: React.FC = ({ 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 = ({ 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 = ({ 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 = { ...prev.part }; for (const rec of records) { @@ -1942,6 +1978,7 @@ const ToolPart: React.FC = ({ childSessionActivity.phase, childSessionHasInFlightTools, childSessionTaskSummaryEntries.length, + currentDirectory, isActive, isTaskTool, taskChildPollingStopped, diff --git a/packages/ui/src/components/chat/message/parts/__tests__/resolveFallbackTaskSessionId.test.js b/packages/ui/src/components/chat/message/parts/__tests__/resolveFallbackTaskSessionId.test.js new file mode 100644 index 00000000..40943064 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/__tests__/resolveFallbackTaskSessionId.test.js @@ -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(); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts b/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts new file mode 100644 index 00000000..b3f6e0d4 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts @@ -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(['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; +} + +/** + * 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; +} diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index a8c4da36..f18edb5d 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -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(() => { 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 { diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts index f91e059e..6485863d 100644 --- a/packages/ui/src/sync/event-pipeline.ts +++ b/packages/ui/src/sync/event-pipeline.ts @@ -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() diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 8bf50c4f..0264c59f 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -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 { 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) { + 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() + + 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>[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) { + const current = store.getState() + const candidateSessionIds = getReconnectCandidateSessionIds(current) + if (candidateSessionIds.length === 0) return + + const nextStatuses = await opencodeClient.getSessionStatusForDirectory(directory) + const relevantStatuses: Record = {} + + 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() + 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]) }