- Removed unused activeTurnAnchorId and activeTurnSpacerHeight from MemoryDebugPanel. - Simplified useChatScrollManager by eliminating unnecessary state and functions related to active turn anchoring. - Updated loadMessages function signatures to accept an optional limit parameter across message and session stores. - Enhanced scroll behavior in useScrollEngine to support dynamic bottom tracking during animations. - Improved event stream handling to allow for resyncing messages with a specified limit. - Cleaned up session memory state to remove active turn properties, focusing on viewport anchoring.
1784 lines
66 KiB
TypeScript
1784 lines
66 KiB
TypeScript
import React from 'react';
|
|
import { opencodeClient, type RoutedOpencodeEvent } from '@/lib/opencode/client';
|
|
import { saveSessionCursor } from '@/lib/messageCursorPersistence';
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
import { getActiveSessionWindow } from '@/stores/types/sessionTypes';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useUIStore, type EventStreamStatus } from '@/stores/useUIStore';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import type { Part, Session, Message } from '@opencode-ai/sdk/v2';
|
|
import type { PermissionRequest } from '@/types/permission';
|
|
import type { QuestionRequest } from '@/types/question';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
|
|
import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
|
|
import { useMcpStore } from '@/stores/useMcpStore';
|
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
import { isWebRuntime } from '@/lib/desktop';
|
|
|
|
interface EventData {
|
|
type: string;
|
|
properties?: Record<string, unknown>;
|
|
}
|
|
|
|
type MessageTracker = (messageId: string, event?: string, extraData?: Record<string, unknown>) => void;
|
|
|
|
declare global {
|
|
interface Window {
|
|
__messageTracker?: MessageTracker;
|
|
}
|
|
}
|
|
|
|
const ENABLE_EMPTY_RESPONSE_DETECTION = false;
|
|
const TEXT_SHRINK_TOLERANCE = 50;
|
|
const RESYNC_DEBOUNCE_MS = 750;
|
|
|
|
const textLengthCache = new WeakMap<Part[], number>();
|
|
const computeTextLength = (parts: Part[] | undefined | null): number => {
|
|
if (!parts || !Array.isArray(parts)) return 0;
|
|
|
|
const cached = textLengthCache.get(parts);
|
|
if (cached !== undefined) return cached;
|
|
|
|
let length = 0;
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (part?.type === 'text') {
|
|
const text = (part as { text?: string; content?: string }).text ?? (part as { text?: string; content?: string }).content;
|
|
if (typeof text === 'string') length += text.length;
|
|
}
|
|
}
|
|
|
|
textLengthCache.set(parts, length);
|
|
return length;
|
|
};
|
|
|
|
const MIN_SORTABLE_LENGTH = 10;
|
|
const extractSortableId = (id: unknown): string | null => {
|
|
if (typeof id !== 'string') return null;
|
|
const trimmed = id.trim();
|
|
if (!trimmed) return null;
|
|
const underscoreIndex = trimmed.indexOf('_');
|
|
const candidate = underscoreIndex >= 0 ? trimmed.slice(underscoreIndex + 1) : trimmed;
|
|
if (!candidate || candidate.length < MIN_SORTABLE_LENGTH) return null;
|
|
return candidate;
|
|
};
|
|
|
|
const isIdNewer = (id: string, referenceId: string): boolean => {
|
|
const currentSortable = extractSortableId(id);
|
|
const referenceSortable = extractSortableId(referenceId);
|
|
if (!currentSortable || !referenceSortable) return true;
|
|
if (currentSortable.length !== referenceSortable.length) return true;
|
|
return currentSortable > referenceSortable;
|
|
};
|
|
|
|
const messageCache = new Map<string, { sessionId: string; message: { info: Message; parts: Part[] } | null }>();
|
|
const getMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => {
|
|
const cacheKey = `${sessionId}:${messageId}`;
|
|
const cached = messageCache.get(cacheKey);
|
|
if (cached && cached.sessionId === sessionId) {
|
|
return cached.message;
|
|
}
|
|
|
|
const storeState = useSessionStore.getState();
|
|
const sessionMessages = storeState.messages.get(sessionId) || [];
|
|
const message = sessionMessages.find(m => m.info.id === messageId) || null;
|
|
|
|
messageCache.set(cacheKey, { sessionId, message });
|
|
return message;
|
|
};
|
|
|
|
const formatModelID = (raw: string): string => {
|
|
if (!raw) {
|
|
return 'Assistant';
|
|
}
|
|
|
|
const tokens: string[] = raw.split(/[-_]/);
|
|
const result: string[] = [];
|
|
let i = 0;
|
|
|
|
while (i < tokens.length) {
|
|
const current = tokens[i];
|
|
|
|
if (/^\d+$/.test(current)) {
|
|
if (i + 1 < tokens.length && /^\d+$/.test(tokens[i + 1])) {
|
|
const combined = `${current}.${tokens[i + 1]}`;
|
|
result.push(combined);
|
|
i += 2;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
result.push(current);
|
|
i += 1;
|
|
}
|
|
|
|
return result
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ');
|
|
};
|
|
|
|
export const useEventStream = () => {
|
|
const {
|
|
addStreamingPart,
|
|
completeStreamingMessage,
|
|
updateMessageInfo,
|
|
updateSessionCompaction,
|
|
addPermission,
|
|
addQuestion,
|
|
dismissQuestion,
|
|
currentSessionId,
|
|
applySessionMetadata,
|
|
sessions,
|
|
getWorktreeMetadata,
|
|
loadMessages,
|
|
loadSessions,
|
|
updateSession,
|
|
removeSessionFromStore
|
|
} = useSessionStore();
|
|
|
|
const { checkConnection } = useConfigStore();
|
|
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
|
|
const notificationMode = useUIStore((state) => state.notificationMode);
|
|
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
|
|
|
const activeSessionDirectory = React.useMemo(() => {
|
|
if (!currentSessionId) return undefined;
|
|
|
|
try {
|
|
const metadata = getWorktreeMetadata?.(currentSessionId);
|
|
if (metadata?.path) return metadata.path;
|
|
} catch (error) {
|
|
console.warn('Failed to inspect worktree metadata for session directory:', error);
|
|
}
|
|
|
|
const sessionRecord = sessions.find((entry) => entry.id === currentSessionId);
|
|
if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) {
|
|
return sessionRecord.directory.trim();
|
|
}
|
|
|
|
return undefined;
|
|
}, [currentSessionId, getWorktreeMetadata, sessions]);
|
|
|
|
const effectiveDirectory = React.useMemo(() => {
|
|
if (activeSessionDirectory && activeSessionDirectory.length > 0) {
|
|
return activeSessionDirectory;
|
|
}
|
|
if (typeof fallbackDirectory === 'string' && fallbackDirectory.trim().length > 0) {
|
|
return fallbackDirectory.trim();
|
|
}
|
|
return undefined;
|
|
}, [activeSessionDirectory, fallbackDirectory]);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const bootstrapPendingPermissions = async () => {
|
|
try {
|
|
const pending = await opencodeClient.listPendingPermissions();
|
|
if (cancelled || pending.length === 0) {
|
|
return;
|
|
}
|
|
|
|
pending.forEach((request) => {
|
|
addPermission(request as unknown as PermissionRequest);
|
|
});
|
|
} catch {
|
|
// ignored
|
|
}
|
|
};
|
|
|
|
const bootstrapPendingQuestions = async () => {
|
|
try {
|
|
const projects = useProjectsStore.getState().projects;
|
|
const projectDirs = projects.map((project) => project.path);
|
|
const sessionDirs = sessions.map((session) => (session as { directory?: string | null }).directory);
|
|
|
|
const directories = [effectiveDirectory, ...projectDirs, ...sessionDirs];
|
|
|
|
const pending = await opencodeClient.listPendingQuestions({ directories });
|
|
if (cancelled || pending.length === 0) {
|
|
return;
|
|
}
|
|
|
|
pending.forEach((request) => {
|
|
addQuestion(request as unknown as QuestionRequest);
|
|
});
|
|
} catch {
|
|
// ignored
|
|
}
|
|
};
|
|
|
|
void bootstrapPendingPermissions();
|
|
void bootstrapPendingQuestions();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [addPermission, addQuestion, effectiveDirectory, sessions]);
|
|
|
|
const normalizeDirectory = React.useCallback((value: string | null | undefined): string | null => {
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const normalized = trimmed.replace(/\\/g, '/');
|
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
}, []);
|
|
|
|
const resolveSessionDirectoryForStatus = React.useCallback(
|
|
(sessionId: string | null | undefined): string | null => {
|
|
if (!sessionId) return null;
|
|
try {
|
|
const metadata = getWorktreeMetadata?.(sessionId);
|
|
const metaPath = normalizeDirectory(metadata?.path ?? null);
|
|
if (metaPath) return metaPath;
|
|
} catch {
|
|
// ignored
|
|
}
|
|
|
|
const record = sessions.find((entry) => entry.id === sessionId);
|
|
return normalizeDirectory((record as { directory?: string | null })?.directory ?? null);
|
|
},
|
|
[getWorktreeMetadata, normalizeDirectory, sessions]
|
|
);
|
|
|
|
const setEventStreamStatus = useUIStore((state) => state.setEventStreamStatus);
|
|
const lastStatusRef = React.useRef<{ status: EventStreamStatus; hint: string | null } | null>(null);
|
|
|
|
const publishStatus = React.useCallback(
|
|
(status: EventStreamStatus, hint?: string | null) => {
|
|
const normalizedHint = hint ?? null;
|
|
const last = lastStatusRef.current;
|
|
if (last && last.status === status && last.hint === normalizedHint) {
|
|
return;
|
|
}
|
|
|
|
lastStatusRef.current = { status, hint: normalizedHint };
|
|
|
|
if (streamDebugEnabled()) {
|
|
const prefixMap: Record<EventStreamStatus, string> = {
|
|
idle: '[IDLE]',
|
|
connecting: '[CONNECT]',
|
|
connected: '[CONNECTED]',
|
|
reconnecting: '[RECONNECT]',
|
|
paused: '[PAUSED]',
|
|
offline: '[OFFLINE]',
|
|
error: '[ERROR]'
|
|
};
|
|
|
|
const prefix = prefixMap[status] ?? '[INFO]';
|
|
const message = normalizedHint ? `${prefix} SSE ${status}: ${normalizedHint}` : `${prefix} SSE ${status}`;
|
|
console.info(message);
|
|
}
|
|
|
|
setEventStreamStatus(status, normalizedHint);
|
|
},
|
|
[setEventStreamStatus]
|
|
);
|
|
|
|
const resyncMessages = React.useCallback(
|
|
(sessionId: string, reason: string, limit?: number) => {
|
|
if (!sessionId) {
|
|
return Promise.resolve();
|
|
}
|
|
const now = Date.now();
|
|
if (resyncInFlightRef.current) {
|
|
return resyncInFlightRef.current;
|
|
}
|
|
if (now - lastResyncAtRef.current < RESYNC_DEBOUNCE_MS) {
|
|
return Promise.resolve();
|
|
}
|
|
const task = loadMessages(sessionId, limit)
|
|
.catch((error) => {
|
|
console.warn(`[useEventStream] Failed to resync messages (${reason}):`, error);
|
|
})
|
|
.finally(() => {
|
|
resyncInFlightRef.current = null;
|
|
lastResyncAtRef.current = Date.now();
|
|
});
|
|
resyncInFlightRef.current = task;
|
|
return task;
|
|
},
|
|
[loadMessages]
|
|
);
|
|
|
|
const bootstrapState = React.useCallback(
|
|
async (reason: string) => {
|
|
if (streamDebugEnabled()) {
|
|
console.info('[useEventStream] Bootstrapping state:', reason);
|
|
}
|
|
try {
|
|
await Promise.all([
|
|
loadSessions(),
|
|
currentSessionId ? resyncMessages(currentSessionId, reason, Infinity) : Promise.resolve(),
|
|
]);
|
|
} catch (error) {
|
|
console.warn('[useEventStream] Bootstrap failed:', reason, error);
|
|
}
|
|
},
|
|
[currentSessionId, loadSessions, resyncMessages]
|
|
);
|
|
|
|
const trackMessage = React.useCallback((messageId: string, event?: string, extraData?: Record<string, unknown>) => {
|
|
if (streamDebugEnabled()) {
|
|
console.debug(`[MessageTracker] ${messageId}: ${event}`, extraData);
|
|
}
|
|
}, []);
|
|
|
|
const reportMessage = React.useCallback((messageId: string) => {
|
|
if (streamDebugEnabled()) {
|
|
console.debug(`[MessageTracker] ${messageId}: reported`);
|
|
}
|
|
}, []);
|
|
|
|
const unsubscribeRef = React.useRef<(() => void) | null>(null);
|
|
const reconnectTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
const reconnectAttemptsRef = React.useRef(0);
|
|
const emptyResponseToastShownRef = React.useRef<Set<string>>(new Set());
|
|
const metadataRefreshTimestampsRef = React.useRef<Map<string, number>>(new Map());
|
|
const sessionRefreshTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
const isCleaningUpRef = React.useRef(false);
|
|
const resyncInFlightRef = React.useRef<Promise<void> | null>(null);
|
|
const lastResyncAtRef = React.useRef(0);
|
|
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
|
|
const questionToastShownRef = React.useRef<Set<string>>(new Set());
|
|
const notifiedMessagesRef = React.useRef<Set<string>>(new Set());
|
|
const notifiedQuestionsRef = React.useRef<Set<string>>(new Set());
|
|
|
|
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
|
if (typeof document === 'undefined') return 'visible';
|
|
|
|
const state = document.visibilityState;
|
|
return state === 'hidden' && document.hasFocus() ? 'visible' : state;
|
|
}, []);
|
|
|
|
const visibilityStateRef = React.useRef<'visible' | 'hidden'>(resolveVisibilityState());
|
|
const onlineStatusRef = React.useRef<boolean>(typeof navigator === 'undefined' ? true : navigator.onLine);
|
|
const pendingResumeRef = React.useRef(false);
|
|
const pauseTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
const staleCheckIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
|
const lastEventTimestampRef = React.useRef<number>(Date.now());
|
|
const isDesktopRuntimeRef = React.useRef<boolean>(false);
|
|
|
|
const maybeBootstrapIfStale = React.useCallback(
|
|
(reason: string) => {
|
|
const now = Date.now();
|
|
if (now - lastEventTimestampRef.current > 25000) {
|
|
void bootstrapState(reason);
|
|
lastEventTimestampRef.current = now;
|
|
}
|
|
},
|
|
[bootstrapState]
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isDesktop?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
|
if (apis?.runtime?.isDesktop) {
|
|
isDesktopRuntimeRef.current = true;
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const sessionCooldownTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
|
|
const sessionActivityPhaseRef = React.useRef<Map<string, 'idle' | 'busy' | 'cooldown'>>(new Map());
|
|
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
|
|
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
|
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
|
|
const previousSessionIdRef = React.useRef<string | null>(null);
|
|
const previousSessionDirectoryRef = React.useRef<string | null>(null);
|
|
React.useEffect(() => {
|
|
currentSessionIdRef.current = currentSessionId;
|
|
}, [currentSessionId]);
|
|
|
|
const requestSessionMetadataRefresh = React.useCallback(
|
|
(sessionId: string | undefined | null, directoryOverride?: string | null) => {
|
|
if (!sessionId) return;
|
|
|
|
const now = Date.now();
|
|
const timestamps = metadataRefreshTimestampsRef.current;
|
|
const lastRefresh = timestamps.get(sessionId);
|
|
|
|
if (lastRefresh && now - lastRefresh < 3000) return;
|
|
|
|
timestamps.set(sessionId, now);
|
|
|
|
const resolveDirectoryForSession = (id: string): string | null => {
|
|
if (typeof directoryOverride === 'string' && directoryOverride.trim().length > 0) {
|
|
return directoryOverride.trim();
|
|
}
|
|
|
|
try {
|
|
const metadata = getWorktreeMetadata?.(id);
|
|
if (metadata?.path) {
|
|
return metadata.path;
|
|
}
|
|
} catch {
|
|
// ignored
|
|
}
|
|
|
|
const sessionRecord = sessions.find((entry) => entry.id === id) as Session & { directory?: string | null };
|
|
if (sessionRecord && typeof sessionRecord.directory === 'string' && sessionRecord.directory.trim().length > 0) {
|
|
return sessionRecord.directory.trim();
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
setTimeout(async () => {
|
|
try {
|
|
const directory = resolveDirectoryForSession(sessionId);
|
|
const session = directory
|
|
? await opencodeClient.withDirectory(directory, () => opencodeClient.getSession(sessionId))
|
|
: await opencodeClient.getSession(sessionId);
|
|
|
|
if (session) {
|
|
const patch: Partial<Session> = {};
|
|
if (typeof session.title === 'string' && session.title.length > 0) {
|
|
patch.title = session.title;
|
|
}
|
|
if (session.summary !== undefined) {
|
|
patch.summary = session.summary;
|
|
}
|
|
if (Object.keys(patch).length > 0) {
|
|
applySessionMetadata(sessionId, patch);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to refresh session metadata:', error);
|
|
}
|
|
}, 100);
|
|
},
|
|
[applySessionMetadata, getWorktreeMetadata, sessions]
|
|
);
|
|
|
|
|
|
const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
|
|
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
|
if (storePhase === phase) {
|
|
sessionActivityPhaseRef.current = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
|
return;
|
|
}
|
|
|
|
const existingTimer = sessionCooldownTimersRef.current.get(sessionId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
sessionCooldownTimersRef.current.delete(sessionId);
|
|
}
|
|
|
|
const next = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
|
next.set(sessionId, phase);
|
|
sessionActivityPhaseRef.current = next;
|
|
useSessionStore.setState({ sessionActivityPhase: next });
|
|
|
|
if (phase === 'cooldown') {
|
|
const timer = setTimeout(() => {
|
|
sessionCooldownTimersRef.current.delete(sessionId);
|
|
const current = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
|
if (current === 'cooldown') {
|
|
const latest = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
|
|
latest.set(sessionId, 'idle');
|
|
sessionActivityPhaseRef.current = latest;
|
|
useSessionStore.setState({ sessionActivityPhase: latest });
|
|
}
|
|
}, 2000);
|
|
sessionCooldownTimersRef.current.set(sessionId, timer);
|
|
}
|
|
}, []);
|
|
|
|
const refreshSessionActivityStatus = React.useCallback(async () => {
|
|
const now = Date.now();
|
|
if (sessionStatusRefreshInFlightRef.current) {
|
|
return sessionStatusRefreshInFlightRef.current;
|
|
}
|
|
if (now - sessionStatusLastRefreshAtRef.current < 1500) {
|
|
return;
|
|
}
|
|
sessionStatusLastRefreshAtRef.current = now;
|
|
|
|
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
|
const observed = new Set<string>();
|
|
const knownSessionIds = new Set(sessions.map((session) => session.id));
|
|
|
|
Object.entries(statusMap).forEach(([sessionId, raw]) => {
|
|
if (!sessionId || !raw) return;
|
|
observed.add(sessionId);
|
|
const phase: 'idle' | 'busy' =
|
|
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
|
|
updateSessionActivityPhase(sessionId, phase);
|
|
});
|
|
|
|
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
|
|
// Treat missing entries as idle to avoid sessions getting stuck "working".
|
|
const currentPhases = useSessionStore.getState().sessionActivityPhase;
|
|
if (!currentPhases) return;
|
|
|
|
for (const [sessionId, phase] of currentPhases.entries()) {
|
|
if (!knownSessionIds.has(sessionId)) continue;
|
|
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
|
|
updateSessionActivityPhase(sessionId, 'idle');
|
|
}
|
|
}
|
|
};
|
|
|
|
const task = (async (): Promise<void> => {
|
|
try {
|
|
const globalStatusMap = await opencodeClient.getGlobalSessionStatus();
|
|
if (globalStatusMap && Object.keys(globalStatusMap).length > 0) {
|
|
applyStatusMap(globalStatusMap);
|
|
return;
|
|
}
|
|
|
|
const directories = new Set<string>();
|
|
sessions.forEach((session) => {
|
|
const directory = resolveSessionDirectoryForStatus(session.id);
|
|
if (directory) directories.add(directory);
|
|
});
|
|
|
|
const effective = normalizeDirectory(effectiveDirectory ?? null);
|
|
if (effective) directories.add(effective);
|
|
|
|
const queries = Array.from(directories);
|
|
if (queries.length === 0) {
|
|
// Fall back to scoped status for whatever the OpenCode client currently tracks.
|
|
const scoped = await opencodeClient.getSessionStatus();
|
|
if (scoped) {
|
|
applyStatusMap(scoped);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const results = await Promise.allSettled(
|
|
queries.map((directory) => opencodeClient.getSessionStatusForDirectory(directory))
|
|
);
|
|
|
|
const merged: Record<string, { type?: string }> = {};
|
|
results.forEach((result) => {
|
|
if (result.status !== 'fulfilled' || !result.value) return;
|
|
Object.assign(merged, result.value);
|
|
});
|
|
|
|
if (Object.keys(merged).length === 0) {
|
|
const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
|
(phase) => phase === 'busy' || phase === 'cooldown'
|
|
);
|
|
|
|
if (hasActivePhases) {
|
|
const healthy = await opencodeClient.checkHealth().catch(() => false);
|
|
if (!healthy) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
applyStatusMap(merged);
|
|
} catch {
|
|
// ignored
|
|
}
|
|
})().finally(() => {
|
|
sessionStatusRefreshInFlightRef.current = null;
|
|
});
|
|
|
|
sessionStatusRefreshInFlightRef.current = task;
|
|
return task;
|
|
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, sessions, updateSessionActivityPhase]);
|
|
|
|
React.useEffect(() => {
|
|
const nextSessionId = currentSessionId ?? null;
|
|
const prevSessionId = previousSessionIdRef.current;
|
|
const nextDirectory = resolveSessionDirectoryForStatus(nextSessionId);
|
|
const prevDirectory = previousSessionDirectoryRef.current;
|
|
|
|
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
|
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
|
void refreshSessionActivityStatus();
|
|
}
|
|
}
|
|
|
|
previousSessionIdRef.current = nextSessionId;
|
|
previousSessionDirectoryRef.current = nextDirectory;
|
|
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
|
|
|
|
const handleEvent = React.useCallback((event: EventData) => {
|
|
lastEventTimestampRef.current = Date.now();
|
|
|
|
if (streamDebugEnabled()) {
|
|
console.debug('[useEventStream] Received event:', event.type, event.properties);
|
|
}
|
|
|
|
if (!event.properties) return;
|
|
|
|
const props = event.properties as Record<string, unknown>;
|
|
const nonMetadataSessionEvents = new Set(['session.abort', 'session.error']);
|
|
|
|
if (!nonMetadataSessionEvents.has(event.type)) {
|
|
const sessionPayload = (typeof props.session === 'object' && props.session !== null ? props.session : null) ||
|
|
(typeof props.sessionInfo === 'object' && props.sessionInfo !== null ? props.sessionInfo : null) as Record<string, unknown> | null;
|
|
|
|
if (sessionPayload) {
|
|
const sessionPayloadAny = sessionPayload as Record<string, unknown>;
|
|
const sessionId = (typeof sessionPayloadAny.id === 'string' && sessionPayloadAny.id.length > 0) ? sessionPayloadAny.id :
|
|
(typeof sessionPayloadAny.sessionID === 'string' && sessionPayloadAny.sessionID.length > 0) ? sessionPayloadAny.sessionID :
|
|
(typeof props.sessionID === 'string' && props.sessionID.length > 0) ? props.sessionID :
|
|
(typeof props.id === 'string' && props.id.length > 0) ? props.id : undefined;
|
|
|
|
if (sessionId) {
|
|
const titleCandidate = typeof sessionPayloadAny.title === 'string' ? sessionPayloadAny.title :
|
|
typeof props.title === 'string' ? props.title : undefined;
|
|
|
|
const summaryCandidate = (typeof sessionPayloadAny.summary === 'object' && sessionPayloadAny.summary !== null) ? sessionPayloadAny.summary as Session['summary'] :
|
|
(typeof props.summary === 'object' && props.summary !== null) ? props.summary as Session['summary'] : undefined;
|
|
|
|
if (titleCandidate !== undefined || summaryCandidate !== undefined) {
|
|
const patch: Partial<Session> = {};
|
|
if (titleCandidate !== undefined) patch.title = titleCandidate;
|
|
if (summaryCandidate !== undefined) patch.summary = summaryCandidate;
|
|
applySessionMetadata(sessionId, patch);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
switch (event.type) {
|
|
case 'server.connected':
|
|
checkConnection();
|
|
break;
|
|
case 'global.disposed':
|
|
case 'server.instance.disposed': {
|
|
void bootstrapState('server_disposed_event');
|
|
break;
|
|
}
|
|
case 'openchamber:session-activity': {
|
|
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
|
|
const phase = typeof props.phase === 'string' ? props.phase : null;
|
|
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
|
updateSessionActivityPhase(sessionId, phase);
|
|
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'mcp.tools.changed': {
|
|
const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory;
|
|
void useMcpStore.getState().refresh({ directory: directory ?? null, silent: true });
|
|
break;
|
|
}
|
|
|
|
case 'session.status':
|
|
if (isDesktopRuntimeRef.current) break;
|
|
{
|
|
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
|
const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record<string, unknown> : null;
|
|
const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null;
|
|
|
|
if (sessionId && statusType) {
|
|
updateSessionActivityPhase(
|
|
sessionId,
|
|
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
|
|
);
|
|
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'message.part.updated': {
|
|
const part = (typeof props.part === 'object' && props.part !== null) ? (props.part as Part) : null;
|
|
if (!part) break;
|
|
|
|
const partExt = part as Record<string, unknown>;
|
|
const messageInfo = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record<string, unknown>) : props;
|
|
|
|
const messageInfoSessionId = typeof (messageInfo as { sessionID?: unknown }).sessionID === 'string'
|
|
? (messageInfo as { sessionID?: string }).sessionID
|
|
: null;
|
|
|
|
const resolvedSessionId =
|
|
(typeof partExt.sessionID === 'string' && (partExt.sessionID as string).length > 0) ? (partExt.sessionID as string) :
|
|
(typeof messageInfoSessionId === 'string' && messageInfoSessionId.length > 0) ? messageInfoSessionId :
|
|
(typeof props.sessionID === 'string' && (props.sessionID as string).length > 0) ? (props.sessionID as string) :
|
|
null;
|
|
|
|
const messageInfoId = typeof (messageInfo as { id?: unknown }).id === 'string'
|
|
? (messageInfo as { id?: string }).id
|
|
: null;
|
|
|
|
const resolvedMessageId =
|
|
(typeof partExt.messageID === 'string' && (partExt.messageID as string).length > 0) ? (partExt.messageID as string) :
|
|
(typeof messageInfoId === 'string' && messageInfoId.length > 0) ? messageInfoId :
|
|
(typeof props.messageID === 'string' && (props.messageID as string).length > 0) ? (props.messageID as string) :
|
|
null;
|
|
|
|
if (!resolvedSessionId || !resolvedMessageId) {
|
|
if (streamDebugEnabled()) {
|
|
console.debug('[useEventStream] Skipping message.part.updated without resolvable session/message id', {
|
|
sessionID: partExt.sessionID ?? messageInfoSessionId ?? props.sessionID,
|
|
messageID: partExt.messageID ?? messageInfoId ?? props.messageID,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
const sessionId = resolvedSessionId;
|
|
const messageId = resolvedMessageId;
|
|
|
|
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
|
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
|
if (streamDebugEnabled()) {
|
|
console.debug('[useEventStream] Skipping message.part.updated for trimmed message', {
|
|
sessionId,
|
|
messageId,
|
|
trimmedHeadMaxId,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
let roleInfo = 'assistant';
|
|
if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') {
|
|
roleInfo = (messageInfo as { role?: string }).role as string;
|
|
} else {
|
|
const existingMessage = getMessageFromStore(sessionId, messageId);
|
|
if (existingMessage) {
|
|
const existingRole = (existingMessage.info as Record<string, unknown>).role;
|
|
if (typeof existingRole === 'string') {
|
|
roleInfo = existingRole;
|
|
}
|
|
}
|
|
}
|
|
|
|
trackMessage(messageId, 'part_received', { role: roleInfo });
|
|
|
|
if (roleInfo === 'user' && partExt.synthetic === true) {
|
|
trackMessage(messageId, 'skipped_synthetic_user_part');
|
|
break;
|
|
}
|
|
|
|
const messagePart: Part = {
|
|
...part,
|
|
type: part.type || 'text',
|
|
} as Part;
|
|
|
|
trackMessage(messageId, 'addStreamingPart_called');
|
|
addStreamingPart(sessionId, messageId, messagePart, roleInfo);
|
|
break;
|
|
}
|
|
|
|
case 'message.updated': {
|
|
const message = (typeof props.info === 'object' && props.info !== null) ? (props.info as Record<string, unknown>) : props;
|
|
const messageExt = message as Record<string, unknown>;
|
|
|
|
const resolvedSessionId =
|
|
(typeof messageExt.sessionID === 'string' && (messageExt.sessionID as string).length > 0) ? (messageExt.sessionID as string) :
|
|
(typeof props.sessionID === 'string' && (props.sessionID as string).length > 0) ? (props.sessionID as string) :
|
|
null;
|
|
|
|
const resolvedMessageId =
|
|
(typeof messageExt.id === 'string' && (messageExt.id as string).length > 0) ? (messageExt.id as string) :
|
|
(typeof props.messageID === 'string' && (props.messageID as string).length > 0) ? (props.messageID as string) :
|
|
null;
|
|
|
|
if (!resolvedSessionId || !resolvedMessageId) {
|
|
if (streamDebugEnabled()) {
|
|
console.debug('[useEventStream] Skipping message.updated without resolvable session/message id', {
|
|
sessionID: messageExt.sessionID ?? props.sessionID,
|
|
messageID: messageExt.id ?? props.messageID,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
const sessionId = resolvedSessionId;
|
|
const messageId = resolvedMessageId;
|
|
|
|
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
|
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
|
if (streamDebugEnabled()) {
|
|
console.debug('[useEventStream] Skipping message.updated for trimmed message', {
|
|
sessionId,
|
|
messageId,
|
|
trimmedHeadMaxId,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (isDesktopRuntimeRef.current && streamDebugEnabled()) {
|
|
try {
|
|
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || [];
|
|
const textParts = Array.isArray(serverParts)
|
|
? serverParts.filter((p: unknown) => (p as { type?: string })?.type === 'text')
|
|
: [];
|
|
const textJoined = textParts
|
|
.map((p: unknown) => {
|
|
const part = p as { text?: string; content?: string };
|
|
return typeof part?.text === 'string' ? part.text : typeof part?.content === 'string' ? part.content : '';
|
|
})
|
|
.join('\n');
|
|
console.info('[STREAM-TRACE] message.updated', {
|
|
messageId,
|
|
role: (messageExt as { role?: unknown }).role,
|
|
status: (messageExt as { status?: unknown }).status,
|
|
textLen: textJoined.length,
|
|
textPreview: textJoined.slice(0, 120),
|
|
partsCount: Array.isArray(serverParts) ? serverParts.length : 0,
|
|
});
|
|
} catch { /* ignored */ }
|
|
}
|
|
|
|
trackMessage(messageId, 'message_updated', { role: (messageExt as { role?: unknown }).role });
|
|
|
|
if ((messageExt as { role?: unknown }).role === 'user') {
|
|
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts;
|
|
const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : [];
|
|
|
|
const userMessageInfo = {
|
|
...message,
|
|
userMessageMarker: true,
|
|
clientRole: 'user',
|
|
} as unknown as Message;
|
|
|
|
updateMessageInfo(sessionId, messageId, userMessageInfo);
|
|
|
|
if (partsArray.length > 0) {
|
|
for (let i = 0; i < partsArray.length; i++) {
|
|
const serverPart = partsArray[i];
|
|
if ((serverPart as Record<string, unknown>).synthetic === true) continue;
|
|
|
|
const enrichedPart: Part = {
|
|
...serverPart,
|
|
type: serverPart?.type || 'text',
|
|
sessionID: (serverPart as { sessionID?: string })?.sessionID || sessionId,
|
|
messageID: (serverPart as { messageID?: string })?.messageID || messageId,
|
|
} as Part;
|
|
addStreamingPart(sessionId, messageId, enrichedPart, 'user');
|
|
}
|
|
}
|
|
|
|
trackMessage(messageId, 'user_message_created_from_event', { partsCount: partsArray.length });
|
|
break;
|
|
}
|
|
|
|
const existingMessage = getMessageFromStore(sessionId, messageId);
|
|
const existingLen = computeTextLength(existingMessage?.parts || []);
|
|
const existingStopMarker = (existingMessage?.info as { finish?: string } | undefined)?.finish === 'stop';
|
|
|
|
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts;
|
|
const partsArray = Array.isArray(serverParts) ? (serverParts as Part[]) : [];
|
|
const hasParts = partsArray.length > 0;
|
|
const timeObj = (messageExt as { time?: { completed?: number } }).time || {};
|
|
const completedFromServer = typeof timeObj?.completed === 'number';
|
|
|
|
if (!hasParts && !completedFromServer) break;
|
|
|
|
const finishCandidate = (message as { finish?: unknown }).finish;
|
|
const finish = typeof finishCandidate === 'string' ? finishCandidate : null;
|
|
const eventHasStopFinish = finish === 'stop';
|
|
|
|
if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) {
|
|
const incomingLen = computeTextLength(partsArray);
|
|
const wouldShrink = existingLen > 0 && incomingLen + TEXT_SHRINK_TOLERANCE < existingLen;
|
|
|
|
if (wouldShrink && !eventHasStopFinish) {
|
|
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
|
|
break;
|
|
}
|
|
|
|
if (isDesktopRuntimeRef.current) {
|
|
const zeroToleranceShrink = existingLen > 0 && incomingLen < existingLen;
|
|
const hasUsefulText = partsArray.some((p) => {
|
|
if (!p || p.type !== 'text') return false;
|
|
const textPart = p as { text?: string };
|
|
return typeof textPart.text === 'string' && textPart.text.length > 0;
|
|
});
|
|
const shrinkAllowed = eventHasStopFinish && hasUsefulText;
|
|
|
|
if (zeroToleranceShrink && !shrinkAllowed) {
|
|
trackMessage(messageId, 'desktop_shrinking_update_suppressed', { incomingLen, existingLen });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
updateMessageInfo(sessionId, messageId, message as unknown as Message);
|
|
|
|
if (hasParts && (messageExt as { role?: unknown }).role !== 'user') {
|
|
const storeState = useSessionStore.getState();
|
|
const existingMessages = storeState.messages.get(sessionId) || [];
|
|
const existingMessageForSession = existingMessages.find((m) => m.info.id === messageId);
|
|
const needsInjection = !existingMessageForSession || existingMessageForSession.parts.length === 0;
|
|
|
|
trackMessage(
|
|
messageId,
|
|
needsInjection ? 'server_parts_injected' : 'server_parts_refreshed',
|
|
{ count: partsArray.length }
|
|
);
|
|
|
|
const partsToInject = isDesktopRuntimeRef.current && (messageExt as { role?: unknown }).role === 'assistant'
|
|
? partsArray.filter((serverPart) => serverPart?.type !== 'text')
|
|
: partsArray;
|
|
|
|
for (let i = 0; i < partsToInject.length; i++) {
|
|
const serverPart = partsToInject[i];
|
|
const enrichedPart: Part = {
|
|
...serverPart,
|
|
type: serverPart?.type || 'text',
|
|
sessionID: serverPart?.sessionID || sessionId,
|
|
messageID: serverPart?.messageID || messageId,
|
|
} as Part;
|
|
addStreamingPart(sessionId, messageId, enrichedPart, (messageExt as { role?: string }).role as string);
|
|
trackMessage(messageId, `server_part_${i}`);
|
|
}
|
|
}
|
|
|
|
const messageTime = (message as { time?: { completed?: unknown } }).time;
|
|
const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed;
|
|
const hasCompletedTimestamp = typeof completedCandidate === 'number' && Number.isFinite(completedCandidate);
|
|
|
|
const stopMarkerPresent = finish === 'stop' || existingStopMarker;
|
|
|
|
const shouldFinalizeAssistantMessage =
|
|
(message as { role?: string }).role === 'assistant' &&
|
|
(hasCompletedTimestamp || stopMarkerPresent);
|
|
|
|
if (shouldFinalizeAssistantMessage && (message as { role?: string }).role === 'assistant') {
|
|
|
|
const storeState = useSessionStore.getState();
|
|
const sessionMessages = storeState.messages.get(sessionId) || [];
|
|
let latestAssistantMessageId: string | null = null;
|
|
let maxId = '';
|
|
|
|
for (let i = 0; i < sessionMessages.length; i++) {
|
|
const msg = sessionMessages[i];
|
|
if (msg.info.role === 'assistant' && msg.info.id > maxId) {
|
|
maxId = msg.info.id;
|
|
latestAssistantMessageId = msg.info.id;
|
|
}
|
|
}
|
|
|
|
const isActiveSession = currentSessionId === sessionId;
|
|
if (isActiveSession && messageId !== latestAssistantMessageId) break;
|
|
|
|
if (!stopMarkerPresent && isDesktopRuntimeRef.current) {
|
|
trackMessage(messageId, 'desktop_completion_without_stop');
|
|
break;
|
|
}
|
|
|
|
const timeCompleted =
|
|
hasCompletedTimestamp
|
|
? (completedCandidate as number)
|
|
: Date.now();
|
|
|
|
if (!hasCompletedTimestamp) {
|
|
updateMessageInfo(sessionId, messageId, {
|
|
...message,
|
|
time: { ...(messageTime ?? {}), completed: timeCompleted },
|
|
} as unknown as Message);
|
|
}
|
|
|
|
trackMessage(messageId, 'completed', { timeCompleted });
|
|
reportMessage(messageId);
|
|
|
|
void saveSessionCursor(sessionId, messageId, timeCompleted);
|
|
|
|
if (ENABLE_EMPTY_RESPONSE_DETECTION) {
|
|
const completedMessage = getMessageFromStore(sessionId, messageId);
|
|
if (completedMessage) {
|
|
const storedParts = Array.isArray(completedMessage.parts) ? completedMessage.parts : [];
|
|
const eventParts = partsArray;
|
|
|
|
const combinedParts: Part[] = [...storedParts];
|
|
for (let i = 0; i < eventParts.length; i++) {
|
|
const rawPart = eventParts[i];
|
|
if (!rawPart) continue;
|
|
|
|
const normalized: Part = {
|
|
...rawPart,
|
|
type: (rawPart as { type?: string }).type || 'text',
|
|
} as Part;
|
|
|
|
const alreadyPresent = combinedParts.some(
|
|
(existing) =>
|
|
existing.id === normalized.id &&
|
|
existing.type === normalized.type &&
|
|
(existing as { callID?: string }).callID === (normalized as { callID?: string }).callID
|
|
);
|
|
|
|
if (!alreadyPresent) {
|
|
combinedParts.push(normalized);
|
|
}
|
|
}
|
|
|
|
let hasStepMarkers = false;
|
|
let hasTextContent = false;
|
|
let hasTools = false;
|
|
let hasReasoning = false;
|
|
let hasFiles = false;
|
|
|
|
for (let i = 0; i < combinedParts.length; i++) {
|
|
const part = combinedParts[i];
|
|
if (!part) continue;
|
|
|
|
if (part.type === 'step-start' || part.type === 'step-finish') {
|
|
hasStepMarkers = true;
|
|
} else if (part.type === 'text') {
|
|
const text = (part as { text?: string }).text;
|
|
if (typeof text === 'string' && text.trim().length > 0) {
|
|
hasTextContent = true;
|
|
}
|
|
} else if (part.type === 'tool') {
|
|
hasTools = true;
|
|
} else if (part.type === 'reasoning') {
|
|
hasReasoning = true;
|
|
} else if (part.type === 'file') {
|
|
hasFiles = true;
|
|
}
|
|
}
|
|
|
|
const hasMeaningfulContent = hasTextContent || hasTools || hasReasoning || hasFiles;
|
|
const isEmptyResponse = !hasMeaningfulContent && !hasStepMarkers;
|
|
|
|
if (isEmptyResponse && !emptyResponseToastShownRef.current.has(messageId)) {
|
|
emptyResponseToastShownRef.current.add(messageId);
|
|
import('sonner').then(({ toast }) => {
|
|
toast.info('Assistant response was empty', {
|
|
description: 'Try sending your message again or rephrase it.',
|
|
duration: 5000,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
completeStreamingMessage(sessionId, messageId);
|
|
|
|
// Only notify when entire message is finished (finish === 'stop')
|
|
if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) {
|
|
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
|
|
|
if (shouldNotify) {
|
|
const notifiedMessages = notifiedMessagesRef.current;
|
|
|
|
if (!notifiedMessages.has(messageId)) {
|
|
notifiedMessages.add(messageId);
|
|
|
|
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
|
|
|
if (runtimeAPIs?.notifications) {
|
|
const rawMode = (messageExt as { mode?: string }).mode || 'agent';
|
|
const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant';
|
|
|
|
const title = `${rawMode.charAt(0).toUpperCase() + rawMode.slice(1)} agent is ready`;
|
|
const body = `${formatModelID(rawModel)} completed the task`;
|
|
|
|
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag: messageId });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For web/vscode: trigger cooldown only when assistant message has finish === "stop"
|
|
// to match desktop backend semantics.
|
|
if (!isDesktopRuntimeRef.current) {
|
|
if (finish === 'stop') {
|
|
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
|
|
if (currentPhase === 'busy') {
|
|
updateSessionActivityPhase(sessionId, 'cooldown');
|
|
}
|
|
}
|
|
}
|
|
|
|
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
|
const messageSessionId: string =
|
|
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
|
|
? rawMessageSessionId
|
|
: sessionId;
|
|
requestSessionMetadataRefresh(
|
|
messageSessionId,
|
|
typeof props.directory === 'string' ? props.directory : null,
|
|
);
|
|
|
|
|
|
const summaryInfo = message as Message & { summary?: boolean };
|
|
if (summaryInfo.summary && typeof messageSessionId === 'string') {
|
|
updateSessionCompaction(messageSessionId, null);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'session.created':
|
|
case 'session.updated': {
|
|
const candidate = (typeof props.info === 'object' && props.info !== null) ? props.info as Record<string, unknown> :
|
|
(typeof props.sessionInfo === 'object' && props.sessionInfo !== null) ? props.sessionInfo as Record<string, unknown> :
|
|
(typeof props.session === 'object' && props.session !== null) ? props.session as Record<string, unknown> : props;
|
|
|
|
const sessionId = (typeof candidate.id === 'string' && candidate.id.length > 0) ? candidate.id :
|
|
(typeof candidate.sessionID === 'string' && candidate.sessionID.length > 0) ? candidate.sessionID :
|
|
(typeof props.sessionID === 'string' && props.sessionID.length > 0) ? props.sessionID :
|
|
(typeof props.id === 'string' && props.id.length > 0) ? props.id : undefined;
|
|
|
|
if (sessionId) {
|
|
const timeSource = (typeof candidate.time === 'object' && candidate.time !== null) ? candidate.time as Record<string, unknown> :
|
|
(typeof props.time === 'object' && props.time !== null) ? props.time as Record<string, unknown> : null;
|
|
const compactingTimestamp = timeSource && typeof timeSource.compacting === 'number' ? timeSource.compacting as number : null;
|
|
updateSessionCompaction(sessionId, compactingTimestamp);
|
|
|
|
const sessionDirectory = typeof (candidate as { directory?: unknown }).directory === 'string'
|
|
? (candidate as { directory: string }).directory
|
|
: typeof props.directory === 'string'
|
|
? (props.directory as string)
|
|
: null;
|
|
|
|
const patchedSession = {
|
|
...(candidate as unknown as Record<string, unknown>),
|
|
id: sessionId,
|
|
...(sessionDirectory ? { directory: sessionDirectory } : {}),
|
|
} as unknown as Session;
|
|
|
|
updateSession(patchedSession);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'session.deleted': {
|
|
const sessionId = typeof props.sessionID === 'string'
|
|
? props.sessionID
|
|
: typeof props.id === 'string'
|
|
? props.id
|
|
: null;
|
|
if (sessionId) {
|
|
removeSessionFromStore(sessionId);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'session.abort': {
|
|
const sessionId =
|
|
typeof props.sessionID === 'string' && (props.sessionID as string).length > 0
|
|
? (props.sessionID as string)
|
|
: null;
|
|
const messageId =
|
|
typeof props.messageID === 'string' && (props.messageID as string).length > 0
|
|
? (props.messageID as string)
|
|
: null;
|
|
|
|
if (sessionId && messageId) {
|
|
completeStreamingMessage(sessionId, messageId);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'permission.asked': {
|
|
if (!('sessionID' in props) || typeof props.sessionID !== 'string') {
|
|
break;
|
|
}
|
|
|
|
const request = props as unknown as PermissionRequest;
|
|
|
|
addPermission(request);
|
|
|
|
// Notify if permission is for another session (common with child sessions).
|
|
const toastKey = `${request.sessionID}:${request.id}`;
|
|
if (!permissionToastShownRef.current.has(toastKey)) {
|
|
setTimeout(() => {
|
|
const current = currentSessionIdRef.current;
|
|
if (current === request.sessionID) {
|
|
return;
|
|
}
|
|
|
|
const pending = useSessionStore
|
|
.getState()
|
|
.permissions
|
|
.get(request.sessionID)
|
|
?.some((entry) => entry.id === request.id);
|
|
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
|
|
permissionToastShownRef.current.add(toastKey);
|
|
|
|
const sessionTitle =
|
|
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
|
|
'Session';
|
|
|
|
import('sonner').then(({ toast }) => {
|
|
toast.warning('Permission required', {
|
|
description: sessionTitle,
|
|
action: {
|
|
label: 'Open',
|
|
onClick: () => {
|
|
useUIStore.getState().setActiveMainTab('chat');
|
|
void useSessionStore.getState().setCurrentSession(request.sessionID);
|
|
},
|
|
},
|
|
});
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case 'permission.replied':
|
|
break;
|
|
|
|
case 'question.asked': {
|
|
if (!('sessionID' in props) || typeof props.sessionID !== 'string') {
|
|
break;
|
|
}
|
|
|
|
const request = props as unknown as QuestionRequest;
|
|
addQuestion(request);
|
|
|
|
const toastKey = `${request.sessionID}:${request.id}`;
|
|
|
|
// Native notification for web runtime (same conditions as completion notifications)
|
|
if (isWebRuntime() && nativeNotificationsEnabled) {
|
|
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
|
|
|
if (shouldNotify) {
|
|
const notifiedQuestions = notifiedQuestionsRef.current;
|
|
|
|
if (!notifiedQuestions.has(toastKey)) {
|
|
notifiedQuestions.add(toastKey);
|
|
|
|
const runtimeAPIs = getRegisteredRuntimeAPIs();
|
|
|
|
if (runtimeAPIs?.notifications) {
|
|
void runtimeAPIs.notifications.notifyAgentCompletion({
|
|
title: 'Input needed',
|
|
body: 'Agent is waiting for your response',
|
|
tag: toastKey,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!questionToastShownRef.current.has(toastKey)) {
|
|
setTimeout(() => {
|
|
const current = currentSessionIdRef.current;
|
|
if (current === request.sessionID) {
|
|
return;
|
|
}
|
|
|
|
const pending = useSessionStore
|
|
.getState()
|
|
.questions
|
|
.get(request.sessionID)
|
|
?.some((entry) => entry.id === request.id);
|
|
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
|
|
questionToastShownRef.current.add(toastKey);
|
|
|
|
const sessionTitle =
|
|
useSessionStore.getState().sessions.find((s) => s.id === request.sessionID)?.title ||
|
|
'Session';
|
|
|
|
import('sonner').then(({ toast }) => {
|
|
toast.info('Input needed', {
|
|
description: sessionTitle,
|
|
action: {
|
|
label: 'Open',
|
|
onClick: () => {
|
|
useUIStore.getState().setActiveMainTab('chat');
|
|
void useSessionStore.getState().setCurrentSession(request.sessionID);
|
|
},
|
|
},
|
|
});
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case 'question.replied': {
|
|
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
|
const requestId = typeof props.requestID === 'string' ? props.requestID : null;
|
|
if (sessionId && requestId) {
|
|
dismissQuestion(sessionId, requestId);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'todo.updated': {
|
|
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
|
|
const todos = Array.isArray(props.todos) ? props.todos : null;
|
|
if (sessionId && todos) {
|
|
handleTodoUpdatedEvent(
|
|
sessionId,
|
|
todos as Array<{ id: string; content: string; status: string; priority: string }>
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}, [
|
|
currentSessionId,
|
|
nativeNotificationsEnabled,
|
|
notificationMode,
|
|
addStreamingPart,
|
|
completeStreamingMessage,
|
|
updateMessageInfo,
|
|
addPermission,
|
|
addQuestion,
|
|
dismissQuestion,
|
|
checkConnection,
|
|
requestSessionMetadataRefresh,
|
|
updateSessionCompaction,
|
|
applySessionMetadata,
|
|
trackMessage,
|
|
reportMessage,
|
|
updateSessionActivityPhase,
|
|
updateSession,
|
|
removeSessionFromStore,
|
|
bootstrapState,
|
|
effectiveDirectory
|
|
]);
|
|
|
|
const shouldHoldConnection = React.useCallback(() => {
|
|
const currentVisibility = resolveVisibilityState();
|
|
visibilityStateRef.current = currentVisibility;
|
|
return currentVisibility === 'visible' && onlineStatusRef.current;
|
|
}, [resolveVisibilityState]);
|
|
|
|
const debugConnectionState = React.useCallback(() => {
|
|
if (streamDebugEnabled()) {
|
|
console.debug('[useEventStream] Connection state:', {
|
|
isDesktopRuntime: isDesktopRuntimeRef.current,
|
|
hasUnsubscribe: Boolean(unsubscribeRef.current),
|
|
currentSessionId: currentSessionIdRef.current,
|
|
effectiveDirectory,
|
|
onlineStatus: onlineStatusRef.current,
|
|
visibilityState: visibilityStateRef.current,
|
|
lastEventTimestamp: lastEventTimestampRef.current,
|
|
reconnectAttempts: reconnectAttemptsRef.current,
|
|
});
|
|
}
|
|
}, [effectiveDirectory]);
|
|
|
|
const waitForDesktopBridge = React.useCallback(async (): Promise<boolean> => true, []);
|
|
|
|
const stopStream = React.useCallback(() => {
|
|
if (isCleaningUpRef.current) {
|
|
if (streamDebugEnabled()) {
|
|
console.info('[useEventStream] Already cleaning up, skipping stopStream');
|
|
}
|
|
return;
|
|
}
|
|
|
|
isCleaningUpRef.current = true;
|
|
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
reconnectTimeoutRef.current = null;
|
|
}
|
|
|
|
if (unsubscribeRef.current) {
|
|
const unsubscribe = unsubscribeRef.current;
|
|
unsubscribeRef.current = null;
|
|
try {
|
|
|
|
unsubscribe();
|
|
} catch (error) {
|
|
console.warn('[useEventStream] Error during unsubscribe:', error);
|
|
}
|
|
}
|
|
|
|
|
|
isCleaningUpRef.current = false;
|
|
}, []);
|
|
|
|
const startStream = React.useCallback(async (options?: { resetAttempts?: boolean }) => {
|
|
debugConnectionState();
|
|
|
|
if (!shouldHoldConnection()) {
|
|
pendingResumeRef.current = true;
|
|
if (!onlineStatusRef.current) {
|
|
publishStatus('offline', 'Waiting for network');
|
|
} else {
|
|
publishStatus('paused', 'Paused while hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isDesktopRuntimeRef.current) {
|
|
const bridgeReady = await waitForDesktopBridge();
|
|
if (!bridgeReady) {
|
|
console.warn('[useEventStream] Desktop bridge not ready, falling back to SDK');
|
|
}
|
|
}
|
|
|
|
if (options?.resetAttempts) {
|
|
reconnectAttemptsRef.current = 0;
|
|
}
|
|
|
|
stopStream();
|
|
lastEventTimestampRef.current = Date.now();
|
|
publishStatus('connecting', null);
|
|
|
|
if (streamDebugEnabled()) {
|
|
console.info('[useEventStream] Starting event stream...');
|
|
}
|
|
|
|
const onError = (error: unknown) => {
|
|
console.warn('Event stream error:', error);
|
|
|
|
};
|
|
|
|
const onOpen = () => {
|
|
const shouldRefresh = pendingResumeRef.current;
|
|
reconnectAttemptsRef.current = 0;
|
|
pendingResumeRef.current = false;
|
|
lastEventTimestampRef.current = Date.now();
|
|
publishStatus('connected', null);
|
|
checkConnection();
|
|
|
|
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
|
(phase) => phase === 'busy' || phase === 'cooldown'
|
|
);
|
|
if (hasBusySessions) {
|
|
void refreshSessionActivityStatus();
|
|
}
|
|
|
|
if (shouldRefresh) {
|
|
void bootstrapState('sse_reconnected');
|
|
} else {
|
|
const sessionId = currentSessionIdRef.current;
|
|
if (sessionId) {
|
|
setTimeout(() => {
|
|
resyncMessages(sessionId, 'sse_reconnected', Infinity)
|
|
.then(() => requestSessionMetadataRefresh(sessionId))
|
|
.catch((error) => {
|
|
console.warn('[useEventStream] Failed to resync messages after reconnect:', error);
|
|
});
|
|
}, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (streamDebugEnabled()) {
|
|
console.info('[useEventStream] Connecting to event source (SDK SSE only):', {
|
|
effectiveDirectory,
|
|
isCleaningUp: isCleaningUpRef.current,
|
|
});
|
|
}
|
|
|
|
if (isCleaningUpRef.current) {
|
|
if (streamDebugEnabled()) {
|
|
console.info('[useEventStream] Skipping subscription due to cleanup in progress');
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const sdkUnsub = opencodeClient.subscribeToGlobalEvents(
|
|
(event: RoutedOpencodeEvent) => {
|
|
const payload = event.payload as unknown as EventData;
|
|
const payloadRecord = event.payload as unknown as Record<string, unknown>;
|
|
const baseProperties =
|
|
typeof payloadRecord.properties === 'object' && payloadRecord.properties !== null
|
|
? (payloadRecord.properties as Record<string, unknown>)
|
|
: {};
|
|
|
|
const properties =
|
|
event.directory && event.directory !== 'global'
|
|
? { ...baseProperties, directory: event.directory }
|
|
: baseProperties;
|
|
|
|
handleEvent({
|
|
type: typeof (payload as { type?: unknown }).type === 'string' ? (payload as { type: string }).type : '',
|
|
properties,
|
|
});
|
|
},
|
|
onError,
|
|
onOpen,
|
|
);
|
|
|
|
|
|
const compositeUnsub = () => {
|
|
try {
|
|
sdkUnsub();
|
|
} catch (cleanupError) {
|
|
console.warn('[useEventStream] Error during unsubscribe:', cleanupError);
|
|
}
|
|
};
|
|
|
|
if (!isCleaningUpRef.current) {
|
|
unsubscribeRef.current = compositeUnsub;
|
|
} else {
|
|
compositeUnsub();
|
|
}
|
|
} catch (subscriptionError) {
|
|
console.error('[useEventStream] Error during subscription:', subscriptionError);
|
|
onError(subscriptionError);
|
|
}
|
|
}, [
|
|
shouldHoldConnection,
|
|
stopStream,
|
|
publishStatus,
|
|
checkConnection,
|
|
resyncMessages,
|
|
requestSessionMetadataRefresh,
|
|
handleEvent,
|
|
effectiveDirectory,
|
|
refreshSessionActivityStatus,
|
|
waitForDesktopBridge,
|
|
debugConnectionState,
|
|
bootstrapState
|
|
]);
|
|
|
|
const scheduleReconnect = React.useCallback((hint?: string) => {
|
|
if (!shouldHoldConnection()) {
|
|
pendingResumeRef.current = true;
|
|
stopStream();
|
|
if (!onlineStatusRef.current) {
|
|
publishStatus('offline', 'Waiting for network');
|
|
} else {
|
|
publishStatus('paused', 'Paused while hidden');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const nextAttempt = reconnectAttemptsRef.current + 1;
|
|
reconnectAttemptsRef.current = nextAttempt;
|
|
const statusHint = hint ?? `Retrying (${nextAttempt})`;
|
|
publishStatus('reconnecting', statusHint);
|
|
|
|
const baseDelay = nextAttempt <= 3
|
|
? Math.min(1000 * Math.pow(2, nextAttempt - 1), 8000)
|
|
: Math.min(2000 * Math.pow(2, nextAttempt - 3), 32000);
|
|
const jitter = Math.floor(Math.random() * 250);
|
|
const delay = baseDelay + jitter;
|
|
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
|
|
reconnectTimeoutRef.current = setTimeout(() => {
|
|
startStream({ resetAttempts: false });
|
|
}, delay);
|
|
}, [shouldHoldConnection, stopStream, publishStatus, startStream]);
|
|
|
|
React.useEffect(() => {
|
|
const cooldownTimers = sessionCooldownTimersRef.current;
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.__messageTracker = trackMessage;
|
|
}
|
|
|
|
let desktopActivityHandler: ((event: CustomEvent<{ sessionId?: string; phase?: string }>) => void) | null = null;
|
|
if (isDesktopRuntimeRef.current && typeof window !== 'undefined') {
|
|
desktopActivityHandler = (event: CustomEvent<{ sessionId?: string; phase?: string }>) => {
|
|
const sessionId = typeof event.detail?.sessionId === 'string' ? event.detail.sessionId : null;
|
|
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
|
|
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
|
updateSessionActivityPhase(sessionId, phase);
|
|
requestSessionMetadataRefresh(sessionId);
|
|
}
|
|
};
|
|
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
|
}
|
|
|
|
const clearPauseTimeout = () => {
|
|
if (pauseTimeoutRef.current) {
|
|
clearTimeout(pauseTimeoutRef.current);
|
|
pauseTimeoutRef.current = null;
|
|
}
|
|
};
|
|
|
|
const pauseStreamSoon = () => {
|
|
if (pauseTimeoutRef.current) return;
|
|
|
|
pauseTimeoutRef.current = setTimeout(() => {
|
|
const pendingVisibility = resolveVisibilityState();
|
|
visibilityStateRef.current = pendingVisibility;
|
|
|
|
if (pendingVisibility !== 'visible') {
|
|
stopStream();
|
|
pendingResumeRef.current = true;
|
|
publishStatus('paused', 'Paused while hidden');
|
|
} else {
|
|
clearPauseTimeout();
|
|
}
|
|
}, 5000);
|
|
};
|
|
|
|
const handleVisibilityChange = () => {
|
|
visibilityStateRef.current = resolveVisibilityState();
|
|
|
|
if (visibilityStateRef.current === 'visible') {
|
|
clearPauseTimeout();
|
|
maybeBootstrapIfStale('visibility_restore');
|
|
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
|
console.info('[useEventStream] Visibility restored, triggering soft refresh...');
|
|
const sessionId = currentSessionIdRef.current;
|
|
if (sessionId) {
|
|
resyncMessages(sessionId, 'visibility_restore', getActiveSessionWindow()).catch(() => {});
|
|
requestSessionMetadataRefresh(sessionId);
|
|
}
|
|
|
|
void refreshSessionActivityStatus();
|
|
publishStatus('connecting', 'Resuming stream');
|
|
startStream({ resetAttempts: true });
|
|
}
|
|
} else {
|
|
publishStatus('paused', 'Paused while hidden');
|
|
pauseStreamSoon();
|
|
}
|
|
};
|
|
|
|
const handleWindowFocus = () => {
|
|
visibilityStateRef.current = resolveVisibilityState();
|
|
|
|
if (visibilityStateRef.current === 'visible') {
|
|
clearPauseTimeout();
|
|
maybeBootstrapIfStale('window_focus');
|
|
|
|
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
|
console.info('[useEventStream] Window focused after pause, triggering soft refresh...');
|
|
const sessionId = currentSessionIdRef.current;
|
|
if (sessionId) {
|
|
requestSessionMetadataRefresh(sessionId);
|
|
resyncMessages(sessionId, 'window_focus', getActiveSessionWindow())
|
|
.then(() => console.info('[useEventStream] Messages refreshed on focus'))
|
|
.catch((err) => console.warn('[useEventStream] Failed to refresh messages:', err));
|
|
}
|
|
void refreshSessionActivityStatus();
|
|
|
|
publishStatus('connecting', 'Resuming stream');
|
|
startStream({ resetAttempts: true });
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleOnline = () => {
|
|
onlineStatusRef.current = true;
|
|
maybeBootstrapIfStale('network_restored');
|
|
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
|
publishStatus('connecting', 'Network restored');
|
|
startStream({ resetAttempts: true });
|
|
}
|
|
};
|
|
|
|
const handleOffline = () => {
|
|
onlineStatusRef.current = false;
|
|
pendingResumeRef.current = true;
|
|
publishStatus('offline', 'Waiting for network');
|
|
stopStream();
|
|
};
|
|
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('online', handleOnline);
|
|
window.addEventListener('offline', handleOffline);
|
|
window.addEventListener('focus', handleWindowFocus);
|
|
}
|
|
|
|
const startTimer = setTimeout(() => {
|
|
startStream({ resetAttempts: true });
|
|
}, 100);
|
|
|
|
if (staleCheckIntervalRef.current) {
|
|
clearInterval(staleCheckIntervalRef.current);
|
|
}
|
|
|
|
staleCheckIntervalRef.current = setInterval(() => {
|
|
if (!shouldHoldConnection()) return;
|
|
|
|
const now = Date.now();
|
|
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
|
|
(phase) => phase === 'busy' || phase === 'cooldown'
|
|
);
|
|
if (hasBusySessions) {
|
|
void refreshSessionActivityStatus();
|
|
}
|
|
if (now - lastEventTimestampRef.current > 45000) {
|
|
Promise.resolve().then(async () => {
|
|
try {
|
|
const healthy = await opencodeClient.checkHealth();
|
|
if (!healthy) {
|
|
scheduleReconnect('Refreshing stalled stream');
|
|
} else {
|
|
lastEventTimestampRef.current = Date.now();
|
|
}
|
|
} catch (error) {
|
|
console.warn('Health check after stale stream failed:', error);
|
|
scheduleReconnect('Refreshing stalled stream');
|
|
}
|
|
});
|
|
}
|
|
}, 10000);
|
|
|
|
return () => {
|
|
clearTimeout(startTimer);
|
|
|
|
if (desktopActivityHandler && typeof window !== 'undefined') {
|
|
window.removeEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
|
|
}
|
|
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('offline', handleOffline);
|
|
window.removeEventListener('focus', handleWindowFocus);
|
|
}
|
|
|
|
clearPauseTimeout();
|
|
|
|
if (staleCheckIntervalRef.current) {
|
|
clearInterval(staleCheckIntervalRef.current);
|
|
staleCheckIntervalRef.current = null;
|
|
}
|
|
|
|
cooldownTimers.forEach((timer) => clearTimeout(timer));
|
|
cooldownTimers.clear();
|
|
messageCache.clear();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
|
notifiedMessagesRef.current.clear();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
|
notifiedQuestionsRef.current.clear();
|
|
|
|
pendingResumeRef.current = false;
|
|
visibilityStateRef.current = resolveVisibilityState();
|
|
onlineStatusRef.current = typeof navigator === 'undefined' ? true : navigator.onLine;
|
|
|
|
stopStream();
|
|
|
|
if (sessionRefreshTimeoutRef.current) {
|
|
clearTimeout(sessionRefreshTimeoutRef.current);
|
|
sessionRefreshTimeoutRef.current = null;
|
|
}
|
|
|
|
publishStatus('idle', null);
|
|
};
|
|
}, [
|
|
effectiveDirectory,
|
|
trackMessage,
|
|
resolveVisibilityState,
|
|
stopStream,
|
|
publishStatus,
|
|
startStream,
|
|
scheduleReconnect,
|
|
loadMessages,
|
|
requestSessionMetadataRefresh,
|
|
updateSessionActivityPhase,
|
|
refreshSessionActivityStatus,
|
|
shouldHoldConnection,
|
|
loadSessions,
|
|
maybeBootstrapIfStale,
|
|
resyncMessages
|
|
]);
|
|
};
|