feat(chat): unify session gateway with stable IDs and a single WS protocol

The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth.
This commit is contained in:
Haileyesus
2026-06-11 18:47:19 +03:00
parent 3d948217ef
commit f5eac2ec12
40 changed files with 2451 additions and 1226 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -24,8 +24,7 @@ function AppContentInner() {
const { sessionId } = useParams<{ sessionId?: string }>();
const { t } = useTranslation('common');
const { isMobile } = useDeviceSettings({ trackPWA: false });
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
const wasConnectedRef = useRef(false);
const { ws, sendMessage, subscribe } = useWebSocket();
const {
processingSessions,
@@ -52,7 +51,7 @@ function AppContentInner() {
} = useProjectsState({
sessionId,
navigate,
latestMessage,
subscribe,
isMobile,
activeSessions: processingSessions,
});
@@ -96,23 +95,9 @@ function AppContentInner() {
};
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
// Permission recovery: query pending permissions on WebSocket reconnect or session change
useEffect(() => {
const isReconnect = isConnected && !wasConnectedRef.current;
if (isReconnect) {
wasConnectedRef.current = true;
} else if (!isConnected) {
wasConnectedRef.current = false;
}
if (isConnected && selectedSession?.id) {
sendMessage({
type: 'get-pending-permissions',
sessionId: selectedSession.id
});
}
}, [isConnected, selectedSession?.id, sendMessage]);
// Pending tool permissions are recovered through the `chat.subscribe` flow:
// the `chat_subscribed` ack carries them on session open and on reconnect,
// so no separate permission-recovery message is needed here.
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
@@ -177,7 +162,6 @@ function AppContentInner() {
setActiveTab={setActiveTab}
ws={ws}
sendMessage={sendMessage}
latestMessage={latestMessage}
isMobile={isMobile}
onMenuClick={() => setSidebarOpen(true)}
isLoading={isLoadingProjects}

View File

@@ -12,7 +12,6 @@ import type {
import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
@@ -45,6 +44,14 @@ interface UseChatComposerStateArgs {
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionProcessing?: MarkSessionProcessing;
/**
* Invoked with the freshly allocated session id when the user sends the
* first message of a brand-new conversation. The backend allocates the id
* via POST /api/providers/sessions BEFORE the websocket send, so the id is
* stable for the conversation's whole lifetime — the consumer navigates to
* /session/:id and records it as the current session.
*/
onSessionEstablished?: (sessionId: string) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
@@ -171,6 +178,7 @@ export function useChatComposerState({
sendMessage,
sendByCtrlEnter,
onSessionProcessing,
onSessionEstablished,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -597,8 +605,49 @@ export function useChatComposerState({
}
}
const effectiveSessionId =
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
// The conversation always has a stable backend-allocated session id
// BEFORE the first websocket send: brand-new chats allocate one here
// via the session gateway. There is no client-visible session-id
// handoff later — this id stays valid for the conversation's lifetime.
let targetSessionId = selectedSession?.id || currentSessionId || null;
if (!targetSessionId) {
try {
const response = await authenticatedFetch('/api/providers/sessions', {
method: 'POST',
body: JSON.stringify({
provider,
projectPath: resolvedProjectPath,
}),
});
if (!response.ok) {
throw new Error(`Failed to create session (${response.status})`);
}
const body = await response.json();
targetSessionId = body?.data?.sessionId || null;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Session creation failed:', error);
addMessage({
type: 'error',
content: `Failed to start a new session: ${message}`,
timestamp: new Date(),
});
return;
}
if (!targetSessionId) {
addMessage({
type: 'error',
content: 'Failed to start a new session: no session id returned.',
timestamp: new Date(),
});
return;
}
onSessionEstablished?.(targetSessionId);
}
const userMessage: ChatMessage = {
type: 'user',
@@ -609,10 +658,9 @@ export function useChatComposerState({
addMessage(userMessage);
// Mark this request as processing in the per-session activity map (the
// single source of truth the indicator derives from). A brand-new
// conversation has no session id yet, so it is tracked under the
// pending placeholder until `session_created` announces the real id.
onSessionProcessing?.(effectiveSessionId || PENDING_SESSION_ID, {
// single source of truth the indicator derives from). The id is always
// concrete at this point — no pending placeholder exists anymore.
onSessionProcessing?.(targetSessionId, {
statusText: null,
canInterrupt: true,
});
@@ -648,87 +696,37 @@ export function useChatComposerState({
};
const toolsSettings = getToolsSettings();
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
if (provider === 'cursor') {
sendMessage({
type: 'cursor-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: cursorModel,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
toolsSettings,
},
});
} else if (provider === 'codex') {
sendMessage({
type: 'codex-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: codexModel,
sessionSummary,
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else if (provider === 'gemini') {
sendMessage({
type: 'gemini-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
sessionSummary,
permissionMode,
toolsSettings,
},
});
} else if (provider === 'opencode') {
sendMessage({
type: 'opencode-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: opencodeModel,
sessionSummary,
},
});
} else {
sendMessage({
type: 'claude-command',
command: messageContent,
options: {
projectPath: resolvedProjectPath,
cwd: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
toolsSettings,
permissionMode,
model: claudeModel,
sessionSummary,
images: uploadedImages,
},
});
}
const model =
provider === 'cursor'
? cursorModel
: provider === 'codex'
? codexModel
: provider === 'gemini'
? geminiModel
: provider === 'opencode'
? opencodeModel
: claudeModel;
// One message shape for every provider. The backend resolves the
// provider, project path, and provider-native resume id from the
// session row; `options` only carries composer-level preferences.
sendMessage({
type: 'chat.send',
sessionId: targetSessionId,
content: messageContent,
options: {
model,
// Codex has no plan mode; downgrade rather than sending an
// unsupported value to its runtime.
permissionMode: provider === 'codex' && permissionMode === 'plan' ? 'default' : permissionMode,
toolsSettings,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
images: uploadedImages,
},
});
setInput('');
inputValueRef.current = '';
@@ -756,6 +754,7 @@ export function useChatComposerState({
opencodeModel,
isLoading,
onSessionProcessing,
onSessionEstablished,
permissionMode,
provider,
resetCommandMenuState,
@@ -918,29 +917,19 @@ export function useChatComposerState({
return;
}
const cursorSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
const candidateSessionIds = [
currentSessionId,
provider === 'cursor' ? cursorSessionId : null,
selectedSession?.id || null,
];
const targetSessionId =
candidateSessionIds.find((sessionId) => Boolean(sessionId)) || null;
const targetSessionId = selectedSession?.id || currentSessionId || null;
if (!targetSessionId) {
console.warn('Abort requested but no concrete session ID is available yet.');
console.warn('Abort requested but no session ID is available.');
return;
}
// The backend resolves the provider from the session row, so no provider
// field is needed here.
sendMessage({
type: 'abort-session',
type: 'chat.abort',
sessionId: targetSessionId,
provider,
});
}, [canAbortSession, currentSessionId, provider, selectedSession?.id, sendMessage]);
}, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]);
const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => {
@@ -965,7 +954,7 @@ export function useChatComposerState({
validIds.forEach((requestId) => {
sendMessage({
type: 'claude-permission-response',
type: 'chat.permission-response',
requestId,
allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput,

View File

@@ -17,17 +17,35 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
opencode: 'anthropic/claude-sonnet-4-5',
};
const getPermissionModesForProvider = (provider: LLMProvider): PermissionMode[] => {
if (provider === 'codex') {
return ['default', 'acceptEdits', 'bypassPermissions'];
}
if (provider === 'claude') {
return ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'];
}
if (provider === 'opencode') {
return ['default'];
}
return ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
/**
* Fallback permission-mode matrix used only until the backend capability
* matrix (`GET /api/providers/capabilities`) has loaded. The backend is the
* source of truth; this mirror exists so the composer renders sensibly on
* first paint and when the capabilities request fails.
*/
const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
claude: ['default', 'auto', 'acceptEdits', 'bypassPermissions', 'plan'],
cursor: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
};
type ProviderCapabilities = {
provider: LLMProvider;
permissionModes: string[];
defaultPermissionMode: string;
supportsImages: boolean;
supportsAbort: boolean;
supportsPermissionRequests: boolean;
supportsTokenUsage: boolean;
};
type ProviderCapabilitiesApiResponse = {
success?: boolean;
data?: {
providers?: ProviderCapabilities[];
};
};
interface UseChatProviderStateArgs {
@@ -76,6 +94,17 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
* mode picker (and is the extension point for future per-provider UI
* differences) so the frontend stays free of hardcoded provider branching.
* Null until `/api/providers/capabilities` resolves; the static fallback
* map covers that window.
*/
const [providerCapabilities, setProviderCapabilities] = useState<
Partial<Record<LLMProvider, ProviderCapabilities>> | null
>(null);
const [providerModelCatalog, setProviderModelCatalog] = useState<
Partial<Record<LLMProvider, ProviderModelsDefinition>>
>({});
@@ -181,6 +210,41 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
void loadProviderModels();
}, [loadProviderModels]);
useEffect(() => {
let cancelled = false;
const loadCapabilities = async () => {
try {
const response = await authenticatedFetch('/api/providers/capabilities');
const body = (await response.json()) as ProviderCapabilitiesApiResponse;
if (cancelled || !body.success || !Array.isArray(body.data?.providers)) {
return;
}
const byProvider: Partial<Record<LLMProvider, ProviderCapabilities>> = {};
for (const capabilities of body.data.providers) {
byProvider[capabilities.provider] = capabilities;
}
setProviderCapabilities(byProvider);
} catch (error) {
console.error('Error loading provider capabilities:', error);
}
};
void loadCapabilities();
return () => {
cancelled = true;
};
}, []);
const getPermissionModesForProvider = useCallback((targetProvider: LLMProvider): PermissionMode[] => {
const capabilityModes = providerCapabilities?.[targetProvider]?.permissionModes;
if (capabilityModes && capabilityModes.length > 0) {
return capabilityModes as PermissionMode[];
}
return FALLBACK_PERMISSION_MODES[targetProvider] ?? ['default'];
}, [providerCapabilities]);
const pickStoredOrCurrent = (
storageKey: string,
current: string,
@@ -269,7 +333,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null;
const validModes = getPermissionModesForProvider(provider);
setPermissionMode(savedMode && validModes.includes(savedMode) ? savedMode : 'default');
}, [selectedSession?.id, provider]);
}, [selectedSession?.id, provider, getPermissionModesForProvider]);
useEffect(() => {
if (!selectedSession?.__provider || selectedSession.__provider === provider) {
@@ -327,7 +391,7 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
if (selectedSession?.id) {
localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);
}
}, [permissionMode, provider, selectedSession?.id]);
}, [permissionMode, provider, selectedSession?.id, getPermissionModesForProvider]);
const selectProviderModel = useCallback(async (
targetProvider: LLMProvider,

View File

@@ -1,67 +1,34 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { PendingPermissionRequest } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type LatestChatMessage = {
type?: string;
kind?: string;
data?: any;
message?: any;
delta?: string;
sessionId?: string;
session_id?: string;
requestId?: string;
toolName?: string;
input?: unknown;
context?: unknown;
error?: string;
tool?: any;
toolId?: string;
result?: any;
exitCode?: number;
isProcessing?: boolean;
actualSessionId?: string;
event?: string;
status?: any;
isNewSession?: boolean;
resultText?: string;
isError?: boolean;
success?: boolean;
reason?: string;
provider?: string;
content?: string;
text?: string;
tokens?: number;
canInterrupt?: boolean;
tokenBudget?: unknown;
newSessionId?: string;
aborted?: boolean;
[key: string]: any;
};
interface UseChatRealtimeHandlersArgs {
latestMessage: LatestChatMessage | null;
subscribe: (listener: (event: ServerEvent) => void) => () => void;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
/**
* Highest live `seq` observed per session. Essential for reconnect catch-up:
* `chat.subscribe` sends this value as `lastSeq` so the server replays only
* the events this client actually missed. Written here on every sequenced
* frame; read wherever a `chat.subscribe` is sent (session open, reconnect).
*/
lastSeqRef: MutableRefObject<Map<string, number>>;
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
onSessionProcessing?: MarkSessionProcessing;
onSessionIdle?: MarkSessionIdle;
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
onWebSocketReconnect?: () => void;
sessionStore: SessionStore;
}
@@ -70,293 +37,259 @@ interface UseChatRealtimeHandlersArgs {
/* Hook */
/* ------------------------------------------------------------------ */
/**
* Routes server events into the session store and processing-state map.
*
* This is intentionally a thin reducer over the unified `kind`-based
* protocol: every frame is keyed by the stable app session id, so there is
* no session-id handoff, no provider branching, and no navigation here.
* Sidebar events (`session_upserted`, `loading_progress`) are handled by
* `useProjectsState`, not in this hook.
*/
export function useChatRealtimeHandlers({
latestMessage,
subscribe,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setTokenBudget,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionProcessing,
onSessionIdle,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
const paletteOps = usePaletteOps();
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
useEffect(() => {
if (!latestMessage) return;
if (lastProcessedMessageRef.current === latestMessage) return;
lastProcessedMessageRef.current = latestMessage;
const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) {
return;
}
const activeViewSessionId =
selectedSession?.id || currentSessionId || null;
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
/* ---------------------------------------------------------------- */
/* Legacy messages (no `kind` field) — handle and return */
/* ---------------------------------------------------------------- */
// Record replay progress for every sequenced live event.
if (sid && typeof msg.seq === 'number') {
const known = lastSeqRef.current.get(sid) ?? 0;
if (msg.seq > known) {
lastSeqRef.current.set(sid, msg.seq);
}
}
const msg = latestMessage as any;
if (!msg.kind) {
const messageType = String(msg.type || '');
switch (messageType) {
case 'websocket-reconnected':
switch (msg.kind) {
case 'websocket_reconnected':
onWebSocketReconnect?.();
return;
case 'pending-permissions-response': {
const permSessionId = msg.sessionId;
const isCurrentPermSession =
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
if (permSessionId && !isCurrentPermSession) return;
setPendingPermissionRequests(msg.data || []);
return;
}
case 'chat_subscribed': {
// Ack for chat.subscribe: authoritative processing state plus any
// pending tool-permission prompts for the run.
if (!sid) return;
case 'session-status': {
const statusSessionId = msg.sessionId;
if (!statusSessionId) return;
const status = msg.status;
if (status) {
onSessionProcessing?.(statusSessionId, {
statusText: status.text || null,
canInterrupt: status.can_interrupt !== false,
});
return;
}
// Reply to check-session-status (or unsolicited processing update)
if (msg.isProcessing) {
onSessionProcessing?.(statusSessionId);
return;
onSessionProcessing?.(sid);
} else {
// Idle ack: ignore it if a newer request started after the
// subscribe was sent — the ack describes the older state.
onSessionIdle?.(sid, {
ifStartedBefore: statusCheckSentAtRef.current.get(sid),
});
}
// Idle reply: ignore it if a newer request started after the check
// was sent — the reply describes the older request.
onSessionIdle?.(statusSessionId, {
ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId),
});
const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
}
return;
}
case 'protocol_error': {
console.error('[Chat] Protocol error:', msg.code, msg.error);
if (sid) {
// Surface the failure in the conversation and stop the spinner —
// the run never started (or was rejected), so no `complete` follows.
onSessionIdle?.(sid);
sessionStore.appendRealtime(sid, {
id: `protocol_error_${Date.now()}`,
sessionId: sid,
timestamp: new Date().toISOString(),
provider,
kind: 'error',
content: String(msg.error || 'Request failed'),
} as NormalizedMessage);
}
return;
}
// Sidebar/global events — owned by useProjectsState.
case 'session_upserted':
case 'loading_progress':
return;
default:
// Unknown legacy message type — ignore
return;
break;
}
}
/* ---------------------------------------------------------------- */
/* NormalizedMessage handling (has `kind` field) */
/* ---------------------------------------------------------------- */
/* -------------------------------------------------------------- */
/* Provider NormalizedMessage handling */
/* -------------------------------------------------------------- */
const sid = msg.sessionId || activeViewSessionId;
// --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') {
const text = msg.content || '';
if (!text) return;
accumulatedStreamRef.current += text;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
if (sid) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
}, 100);
}
// Also route to store for non-active sessions
if (sid && sid !== activeViewSessionId) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
return;
}
if (msg.kind === 'stream_end') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid) {
if (accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
// --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') {
const text = (msg.content as string) || '';
if (!text) return;
accumulatedStreamRef.current += text;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
if (sid) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
}, 100);
}
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
return;
}
// --- All other messages: route to store ---
const shouldPersist =
msg.kind !== 'session_created'
&& msg.kind !== 'complete'
&& msg.kind !== 'status'
&& msg.kind !== 'permission_request'
&& msg.kind !== 'permission_cancelled';
if (sid && shouldPersist) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
// --- UI side effects for specific kinds ---
switch (msg.kind) {
case 'session_created': {
const newSessionId = msg.newSessionId;
if (!newSessionId) break;
// We no longer synthesize client-side placeholder IDs. Until the provider
// announces `session_created`, the active id is expected to be null.
if (!currentSessionId) {
setCurrentSessionId(newSessionId);
setPendingPermissionRequests((prev) =>
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
);
// Also route to store for non-active sessions
if (sid && sid !== activeViewSessionId) {
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
}
// The in-flight request now has a concrete session id: migrate the
// processing entry from the pending placeholder.
onSessionIdle?.(PENDING_SESSION_ID);
onSessionProcessing?.(newSessionId);
onNavigateToSession?.(newSessionId);
break;
return;
}
case 'complete': {
// Flush any remaining streaming state
if (msg.kind === 'stream_end') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid && accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
if (sid) {
if (accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
return;
}
// `complete` is the unified terminal event — every provider run ends
// with exactly one, regardless of success, failure, or abort. The
// indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically.
onSessionIdle?.(sid);
onSessionIdle?.(PENDING_SESSION_ID);
setPendingPermissionRequests([]);
// --- All other messages: route to store ---
const shouldPersist =
msg.kind !== 'complete'
&& msg.kind !== 'status'
&& msg.kind !== 'permission_request'
&& msg.kind !== 'permission_cancelled';
if (sid && shouldPersist) {
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
}
// --- UI side effects for specific kinds ---
switch (msg.kind) {
case 'complete': {
// Flush any remaining streaming state
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid && accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
// `complete` is the unified terminal event — every provider run ends
// with exactly one, regardless of success, failure, or abort. The
// indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically.
onSessionIdle?.(sid);
setPendingPermissionRequests([]);
if (msg.aborted) {
// Abort was requested — the complete event confirms it. No
// further UI action is needed beyond clearing the entry above.
break;
}
// Celebrate only successful runs (failed runs end with success: false).
if (msg.success !== false) {
showCompletionTitleIndicator();
void playChatCompletionSound();
}
// The session id is stable for the whole conversation (allocated
// before the first send), so the only follow-up is syncing the
// viewed conversation with the now-persisted transcript.
if (sid && sid === activeViewSessionId) {
void sessionStore.refreshFromServer(sid);
}
// Handle aborted case
if (msg.aborted) {
// Abort was requested — the complete event confirms it
// No special UI action needed beyond clearing the processing entry above
// The backend already sent any abort-related messages
break;
}
// Celebrate only successful runs (failed runs end with success: false).
if (msg.success !== false) {
showCompletionTitleIndicator();
void playChatCompletionSound();
}
// 'error' is an informational message row, not a terminal event —
// providers emit it for mid-run stderr output too. Run teardown is
// always signalled by the unified 'complete' that follows.
const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId
: null;
const isVisibleSession =
Boolean(
sid
&& sid === activeViewSessionId,
);
if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId);
onSessionIdle?.(actualSessionId);
if (isVisibleSession) {
setCurrentSessionId(actualSessionId);
void sessionStore.refreshFromServer(actualSessionId);
}
if (isVisibleSession) {
onNavigateToSession?.(actualSessionId, { replace: true });
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break;
}
if (sid && isVisibleSession) {
void sessionStore.refreshFromServer(sid);
}
break;
}
// 'error' is an informational message row, not a terminal event —
// providers emit it for mid-run stderr output too. Run teardown is
// always signalled by the unified 'complete' that follows.
case 'permission_request': {
if (!msg.requestId) break;
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId,
toolName: msg.toolName || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
onSessionProcessing?.(sid || PENDING_SESSION_ID);
break;
}
case 'permission_cancelled': {
if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
case 'status': {
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) {
onSessionProcessing?.(sid || PENDING_SESSION_ID, {
statusText: msg.text,
canInterrupt: msg.canInterrupt !== false,
case 'permission_request': {
if (!msg.requestId) break;
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
if (sid) {
onSessionProcessing?.(sid);
}
break;
}
break;
}
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
// → already routed to store above, no UI side effects needed
default:
break;
}
case 'permission_cancelled': {
if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
case 'status': {
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text && sid) {
onSessionProcessing?.(sid, {
statusText: msg.text as string,
canInterrupt: msg.canInterrupt !== false,
});
}
break;
}
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
// → already routed to store above, no UI side effects needed
default:
break;
}
};
return subscribe(handleEvent);
}, [
latestMessage,
subscribe,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setTokenBudget,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionProcessing,
onSessionIdle,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
paletteOps,
]);
}

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { MutableRefObject } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
@@ -25,8 +24,10 @@ interface UseChatSessionStateArgs {
processingSessions?: SessionActivityMap;
onSessionIdle?: MarkSessionIdle;
resetStreamingState: () => void;
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
/** Highest live seq observed per session; sent as `lastSeq` on subscribe. */
lastSeqRef: MutableRefObject<Map<string, number>>;
sessionStore: SessionStore;
}
@@ -102,6 +103,7 @@ export function useChatSessionState({
onSessionIdle,
resetStreamingState,
statusCheckSentAtRef,
lastSeqRef,
sessionStore,
}: UseChatSessionStateArgs) {
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
@@ -168,10 +170,8 @@ export function useChatSessionState({
* - No coupling to unrelated external update signals.
*/
resetStreamingState();
onSessionIdle?.(PENDING_SESSION_ID);
setCurrentSessionId(null);
setPendingUserMessage(null);
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
@@ -208,9 +208,10 @@ export function useChatSessionState({
const activeSessionId = selectedSession?.id || currentSessionId || null;
// The activity indicator always reflects the latest status of the session
// being viewed (or of the pending not-yet-created session on a fresh
// draft) — never stale local UI state from the last time it was open.
const sessionActivity = processingSessions?.get(activeSessionId ?? PENDING_SESSION_ID) ?? null;
// being viewed — never stale local UI state from the last time it was
// open. Session ids are concrete before any send, so no pending
// placeholder entry exists anymore.
const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null;
const isProcessing = sessionActivity !== null;
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
@@ -440,15 +441,15 @@ export function useChatSessionState({
// Main session loading effect — store-based
useEffect(() => {
if (!selectedSession || !selectedProject) {
// A new provider run can be in flight before the router has a canonical
// selectedSession. Keep the draft view intact until complete/error.
if (processingSessionsRef.current?.has(PENDING_SESSION_ID)) {
// A freshly created session can be mid-run before the router has a
// canonical selectedSession (the URL effect synthesizes one on the
// next render). Keep the active view intact instead of wiping it.
if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) {
return;
}
resetStreamingState();
setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId');
messagesOffsetRef.current = 0;
setHasMoreMessages(false);
setTotalMessages(0);
@@ -489,16 +490,21 @@ export function useChatSessionState({
}
setCurrentSessionId(selectedSession.id);
if (provider === 'cursor') {
sessionStorage.setItem('cursorSessionId', selectedSession.id);
}
// Reconcile processing state with the server. Recording the send time
// lets the reply handler discard idle replies that a newer request has
// Subscribe to the session's live run (if any): the ack reconciles the
// processing indicator, re-attaches a mid-flight stream to this socket,
// and replays any live events missed since `lastSeq`. Recording the send
// time lets the ack handler discard idle acks that a newer request has
// since outdated.
if (ws) {
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSession.id,
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
}],
});
}
lastLoadedSessionKeyRef.current = sessionKey;
@@ -527,6 +533,7 @@ export function useChatSessionState({
selectedSession?.id,
sendMessage,
statusCheckSentAtRef,
lastSeqRef,
ws,
sessionStore,
]);

View File

@@ -112,7 +112,6 @@ export interface ChatInterfaceProps {
selectedSession: ProjectSession | null;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
latestMessage: any;
onFileOpen?: (filePath: string, diffInfo?: any) => void;
onInputFocusChange?: (focused: boolean) => void;
onSessionProcessing?: MarkSessionProcessing;

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { useWebSocket } from '../../../contexts/WebSocketContext';
import PermissionContext from '../../../contexts/PermissionContext';
import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types';
@@ -22,7 +23,6 @@ function ChatInterface({
selectedSession,
ws,
sendMessage,
latestMessage,
onFileOpen,
onInputFocusChange,
onSessionProcessing,
@@ -40,14 +40,19 @@ function ChatInterface({
onShowAllTasks,
}: ChatInterfaceProps) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { subscribe } = useWebSocket();
const { t } = useTranslation('chat');
const sessionStore = useSessionStore();
const streamTimerRef = useRef<number | null>(null);
const accumulatedStreamRef = useRef('');
// When each session's `check-session-status` was last sent; idle replies
// older than a later local request are discarded as stale.
// When each session's `chat.subscribe` was last sent; idle acks older than
// a later local request are discarded as stale.
const statusCheckSentAtRef = useRef(new Map<string, number>());
// Highest live `seq` observed per session. Written by the realtime handler
// on every sequenced frame, read whenever a `chat.subscribe` is sent so the
// server replays only the events this client actually missed.
const lastSeqRef = useRef(new Map<string, number>());
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
@@ -126,9 +131,18 @@ function ChatInterface({
onSessionIdle,
resetStreamingState,
statusCheckSentAtRef,
lastSeqRef,
sessionStore,
});
// Brand-new conversation: the composer allocated a stable session id via
// the session gateway before the first send. Record it locally and put it
// in the URL — this id never changes again, so there is no later handoff.
const handleSessionEstablished = useCallback((sessionId: string) => {
setCurrentSessionId(sessionId);
onNavigateToSession?.(sessionId);
}, [setCurrentSessionId, onNavigateToSession]);
const {
input,
setInput,
@@ -191,6 +205,7 @@ function ChatInterface({
sendMessage,
sendByCtrlEnter,
onSessionProcessing,
onSessionEstablished: handleSessionEstablished,
onInputFocusChange,
onFileOpen,
onShowSettings,
@@ -201,9 +216,9 @@ function ChatInterface({
});
// On WebSocket reconnect, re-fetch the current session's messages from the
// server so missed streaming events are shown, then re-check the session's
// processing status — the authoritative reply restores or clears the
// activity indicator depending on whether the run is still active.
// server so missed streaming events are shown, then re-subscribe — the
// `chat_subscribed` ack restores or clears the activity indicator, replays
// missed live events, and re-attaches a still-running stream to this socket.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
const providerVal =
@@ -217,23 +232,28 @@ function ChatInterface({
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider: providerVal });
sendMessage({
type: 'chat.subscribe',
sessions: [{
sessionId: selectedSession.id,
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
}],
});
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
useChatRealtimeHandlers({
latestMessage,
subscribe,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setTokenBudget,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionProcessing,
onSessionIdle,
onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect,
sessionStore,
});

View File

@@ -44,7 +44,6 @@ export type MainContentProps = {
setActiveTab: Dispatch<SetStateAction<AppTab>>;
ws: WebSocket | null;
sendMessage: (message: unknown) => void;
latestMessage: unknown;
isMobile: boolean;
onMenuClick: () => void;
isLoading: boolean;

View File

@@ -37,7 +37,6 @@ function MainContent({
setActiveTab,
ws,
sendMessage,
latestMessage,
isMobile,
onMenuClick,
isLoading,
@@ -126,7 +125,6 @@ function MainContent({
selectedSession={selectedSession}
ws={ws}
sendMessage={sendMessage}
latestMessage={latestMessage}
onFileOpen={handleFileOpen}
onInputFocusChange={onInputFocusChange}
onSessionProcessing={onSessionProcessing}

View File

@@ -2,10 +2,42 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { useAuth } from '../components/auth/context/AuthContext';
import { IS_PLATFORM } from '../constants/config';
/**
* One frame received from the chat websocket. The server guarantees every
* frame carries a `kind` (provider message kinds plus gateway kinds such as
* `chat_subscribed`, `session_upserted`, `loading_progress`,
* `protocol_error`). The synthetic `websocket_reconnected` kind is injected
* client-side when the socket re-opens after a drop.
*/
export type ServerEvent = {
kind?: string;
type?: string;
sessionId?: string;
seq?: number;
[key: string]: unknown;
};
type ServerEventListener = (event: ServerEvent) => void;
type WebSocketContextType = {
ws: WebSocket | null;
sendMessage: (message: any) => void;
latestMessage: any | null;
sendMessage: (message: unknown) => void;
/**
* Subscribes to every websocket frame. Returns an unsubscribe function.
*
* This is the primary consumption API: events are dispatched synchronously
* to every listener, so rapid back-to-back frames can never be coalesced or
* dropped the way a single "latest message" state slot could.
*/
subscribe: (listener: ServerEventListener) => () => void;
/**
* Legacy state-based access to the most recent frame.
*
* Kept only for low-frequency consumers (TaskMaster broadcasts). High-rate
* chat streams must use `subscribe` — React may batch state updates, which
* makes `latestMessage` lossy under load.
*/
latestMessage: ServerEvent | null;
isConnected: boolean;
};
@@ -30,11 +62,28 @@ const useWebSocketProviderState = (): WebSocketContextType => {
const wsRef = useRef<WebSocket | null>(null);
const unmountedRef = useRef(false); // Track if component is unmounted
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
const [latestMessage, setLatestMessage] = useState<any>(null);
/**
* Listener registry for the subscribe API. A ref (not state) because the
* set must be readable synchronously inside `onmessage` and never trigger
* re-renders of the provider tree.
*/
const listenersRef = useRef(new Set<ServerEventListener>());
const [latestMessage, setLatestMessage] = useState<ServerEvent | null>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const { token } = useAuth();
const dispatch = useCallback((event: ServerEvent) => {
for (const listener of listenersRef.current) {
try {
listener(event);
} catch (error) {
console.error('WebSocket listener error:', error);
}
}
setLatestMessage(event);
}, []);
useEffect(() => {
// The cleanup below sets unmountedRef = true. Without this reset, every
// re-run of the effect (e.g. on token refresh) would short-circuit connect()
@@ -60,7 +109,7 @@ const useWebSocketProviderState = (): WebSocketContextType => {
const wsUrl = buildWebSocketUrl(token);
if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
@@ -68,15 +117,15 @@ const useWebSocketProviderState = (): WebSocketContextType => {
wsRef.current = websocket;
if (hasConnectedRef.current) {
// This is a reconnect — signal so components can catch up on missed messages
setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });
dispatch({ kind: 'websocket_reconnected', timestamp: Date.now() });
}
hasConnectedRef.current = true;
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setLatestMessage(data);
const data = JSON.parse(event.data) as ServerEvent;
dispatch(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
@@ -85,7 +134,7 @@ const useWebSocketProviderState = (): WebSocketContextType => {
websocket.onclose = () => {
setIsConnected(false);
wsRef.current = null;
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
if (unmountedRef.current) return; // Prevent reconnection if unmounted
@@ -100,9 +149,9 @@ const useWebSocketProviderState = (): WebSocketContextType => {
} catch (error) {
console.error('Error creating WebSocket connection:', error);
}
}, [token]); // everytime token changes, we reconnect
}, [token, dispatch]); // everytime token changes, we reconnect
const sendMessage = useCallback((message: any) => {
const sendMessage = useCallback((message: unknown) => {
const socket = wsRef.current;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
@@ -111,20 +160,28 @@ const useWebSocketProviderState = (): WebSocketContextType => {
}
}, []);
const subscribe = useCallback((listener: ServerEventListener) => {
listenersRef.current.add(listener);
return () => {
listenersRef.current.delete(listener);
};
}, []);
const value: WebSocketContextType = useMemo(() =>
({
ws: wsRef.current,
sendMessage,
subscribe,
latestMessage,
isConnected
}), [sendMessage, latestMessage, isConnected]);
}), [sendMessage, subscribe, latestMessage, isConnected]);
return value;
};
export const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {
const webSocketData = useWebSocketProviderState();
return (
<WebSocketContext.Provider value={webSocketData}>
{children}

View File

@@ -2,14 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import { api } from '../utils/api';
import type { ServerEvent } from '../contexts/WebSocketContext';
import type {
AppSocketMessage,
AppTab,
LLMProvider,
LoadingProgress,
Project,
ProjectSession,
ProjectsUpdatedMessage,
} from '../types/app';
import type { SessionActivityMap } from './useSessionProtection';
@@ -17,11 +16,30 @@ import type { SessionActivityMap } from './useSessionProtection';
type UseProjectsStateArgs = {
sessionId?: string;
navigate: NavigateFunction;
latestMessage: AppSocketMessage | null;
/** Subscription to the unified websocket event stream. */
subscribe: (listener: (event: ServerEvent) => void) => () => void;
isMobile: boolean;
activeSessions: SessionActivityMap;
};
/**
* Shape of the per-session sidebar delta broadcast by the backend file
* watcher (`kind: session_upserted`). It carries everything needed to upsert
* one session row in place — no full project-list snapshot is ever pushed.
*/
type SessionUpsertedEvent = ServerEvent & {
sessionId: string;
provider: LLMProvider;
session: ProjectSession;
project: {
projectId: string;
path: string;
fullPath: string;
displayName: string;
isStarred: boolean;
} | null;
};
type FetchProjectsOptions = {
showLoadingState?: boolean;
};
@@ -187,40 +205,57 @@ const mergeProjectSessionPage = (
return mergedProject;
};
const isUpdateAdditive = (
currentProjects: Project[],
updatedProjects: Project[],
selectedProject: Project | null,
selectedSession: ProjectSession | null,
): boolean => {
if (!selectedProject || !selectedSession) {
return true;
/**
* Resolves which provider bucket on a `Project` holds sessions for a provider.
* The legacy payload keeps Claude sessions in `sessions` and the other
* providers in their own arrays.
*/
const providerBucketKey = (
provider: LLMProvider,
): 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' => {
if (provider === 'cursor') return 'cursorSessions';
if (provider === 'codex') return 'codexSessions';
if (provider === 'gemini') return 'geminiSessions';
if (provider === 'opencode') return 'opencodeSessions';
return 'sessions';
};
/**
* Upserts one session into the matching provider bucket of a project.
*
* Existing rows are updated in place (summary/lastActivity changes from the
* watcher); new rows are prepended since the watcher only fires for sessions
* with fresh activity. `sessionMeta.total` grows only on insert.
*/
const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent): Project => {
const bucketKey = providerBucketKey(event.provider);
const bucket = project[bucketKey] ?? [];
const existingIndex = bucket.findIndex((session) => session.id === event.sessionId);
let nextBucket: ProjectSession[];
if (existingIndex >= 0) {
const existing = bucket[existingIndex];
const updated = { ...existing, ...event.session };
if (serialize(existing) === serialize(updated)) {
return project;
}
nextBucket = [...bucket];
nextBucket[existingIndex] = updated;
} else {
nextBucket = [event.session, ...bucket];
}
const currentSelectedProject = currentProjects.find((project) => project.projectId === selectedProject.projectId);
const updatedSelectedProject = updatedProjects.find((project) => project.projectId === selectedProject.projectId);
if (!currentSelectedProject || !updatedSelectedProject) {
return false;
const next: Project = { ...project, [bucketKey]: nextBucket };
if (existingIndex < 0) {
const total = Number(project.sessionMeta?.total ?? 0) + 1;
next.sessionMeta = {
...project.sessionMeta,
total,
hasMore: countLoadedProjectSessions(next) < total,
};
}
const currentSelectedSession = getProjectSessions(currentSelectedProject).find(
(session) => session.id === selectedSession.id,
);
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
(session) => session.id === selectedSession.id,
);
if (!currentSelectedSession || !updatedSelectedSession) {
return false;
}
return (
currentSelectedSession.id === updatedSelectedSession.id &&
currentSelectedSession.title === updatedSelectedSession.title &&
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
currentSelectedSession.updated_at === updatedSelectedSession.updated_at
);
return next;
};
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
@@ -244,7 +279,7 @@ const readPersistedTab = (): AppTab => {
export function useProjectsState({
sessionId,
navigate,
latestMessage,
subscribe,
isMobile,
activeSessions,
}: UseProjectsStateArgs) {
@@ -291,7 +326,18 @@ export function useProjectsState({
const [newSessionTrigger, setNewSessionTrigger] = useState(0);
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHandledMessageRef = useRef<AppSocketMessage | null>(null);
/**
* Ref mirrors for state the websocket subscription handler needs.
*
* The subscription is registered once (per `subscribe` identity) and events
* are dispatched synchronously outside React's render cycle, so the handler
* must read the latest values through refs instead of stale closures —
* re-subscribing on every state change would risk missing events.
*/
const selectedSessionRef = useRef(selectedSession);
selectedSessionRef.current = selectedSession;
const activeSessionsRef = useRef(activeSessions);
activeSessionsRef.current = activeSessions;
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
try {
@@ -393,98 +439,109 @@ export function useProjectsState({
}
}, [isLoadingProjects, projects, selectedProject, sessionId]);
// Realtime sidebar updates. The backend pushes per-session deltas
// (`session_upserted`) instead of full project snapshots, so each event is
// a keyed upsert that can never clobber unrelated client state — no
// "suppress updates while a run is active" protection is needed anymore.
useEffect(() => {
if (!latestMessage) {
return;
}
// `latestMessage` is event-like data. This effect also depends on local state
// (`projects`, `selectedProject`, `selectedSession`) to compute derived updates.
// Without this guard, handling one websocket message can update that local
// state, retrigger the effect, and re-handle the same websocket message.
if (lastHandledMessageRef.current === latestMessage) {
return;
}
lastHandledMessageRef.current = latestMessage;
if (latestMessage.type === 'loading_progress') {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
loadingProgressTimeoutRef.current = null;
}
setLoadingProgress(latestMessage as LoadingProgress);
if (latestMessage.phase === 'complete') {
loadingProgressTimeoutRef.current = setTimeout(() => {
setLoadingProgress(null);
const handleEvent = (event: ServerEvent) => {
if (event.kind === 'loading_progress') {
if (loadingProgressTimeoutRef.current) {
clearTimeout(loadingProgressTimeoutRef.current);
loadingProgressTimeoutRef.current = null;
}, 500);
}
return;
}
if (latestMessage.type !== 'projects_updated') {
return;
}
const projectsMessage = latestMessage as ProjectsUpdatedMessage;
if (projectsMessage.updatedSessionId && selectedSession && selectedProject) {
if (projectsMessage.updatedSessionId === selectedSession.id) {
const isSessionActive = activeSessions.has(selectedSession.id);
if (!isSessionActive) {
setExternalMessageUpdate((prev) => prev + 1);
}
setLoadingProgress(event as unknown as LoadingProgress);
if (event.phase === 'complete') {
loadingProgressTimeoutRef.current = setTimeout(() => {
setLoadingProgress(null);
loadingProgressTimeoutRef.current = null;
}, 500);
}
return;
}
}
const hasActiveSession = Boolean(selectedSession && activeSessions.has(selectedSession.id));
if (event.kind !== 'session_upserted') {
return;
}
const updatedProjectsWithTaskMaster = mergeTaskMasterCache(projectsMessage.projects, projects);
const updatedProjects = mergeExpandedSessionPages(projects, updatedProjectsWithTaskMaster);
const upsert = event as SessionUpsertedEvent;
if (!upsert.sessionId || !upsert.session) {
return;
}
if (
hasActiveSession &&
!isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)
) {
return;
}
// The transcript of the currently viewed session changed on disk while
// no run is active here (e.g. edited from another client or the CLI):
// signal the chat view to reload its messages.
const currentSelectedSession = selectedSessionRef.current;
if (
currentSelectedSession
&& upsert.sessionId === currentSelectedSession.id
&& !activeSessionsRef.current.has(upsert.sessionId)
) {
setExternalMessageUpdate((prev) => prev + 1);
}
setProjects((previousProjects) =>
projectsHaveChanges(previousProjects, updatedProjects, true) ? updatedProjects : previousProjects,
);
setProjects((previousProjects) => {
const targetProjectId = upsert.project?.projectId;
const existingProject = previousProjects.find((project) =>
targetProjectId ? project.projectId === targetProjectId : getProjectSessions(project).some((session) => session.id === upsert.sessionId),
);
if (!selectedProject) {
return;
}
if (!existingProject) {
// First session of a project this client has never seen: create the
// project entry from the event payload.
if (!upsert.project) {
return previousProjects;
}
const updatedSelectedProject = updatedProjects.find(
(project) => project.projectId === selectedProject.projectId,
);
const newProject: Project = {
projectId: upsert.project.projectId,
path: upsert.project.path,
fullPath: upsert.project.fullPath,
displayName: upsert.project.displayName,
isStarred: upsert.project.isStarred,
sessions: [],
cursorSessions: [],
codexSessions: [],
geminiSessions: [],
opencodeSessions: [],
sessionMeta: { hasMore: false, total: 0 },
} as Project;
if (!updatedSelectedProject) {
return;
}
return [...previousProjects, upsertSessionIntoProject(newProject, upsert)];
}
if (serialize(updatedSelectedProject) !== serialize(selectedProject)) {
setSelectedProject(updatedSelectedProject);
}
const updatedProject = upsertSessionIntoProject(existingProject, upsert);
if (updatedProject === existingProject) {
return previousProjects;
}
if (!selectedSession) {
return;
}
return previousProjects.map((project) =>
project.projectId === existingProject.projectId ? updatedProject : project,
);
});
const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(
(session) => session.id === selectedSession.id,
);
// Keep the selected project reference in sync with the upsert.
setSelectedProject((previousProject) => {
if (!previousProject) {
return previousProject;
}
const matches = upsert.project
? previousProject.projectId === upsert.project.projectId
: getProjectSessions(previousProject).some((session) => session.id === upsert.sessionId);
if (!matches) {
return previousProject;
}
const updated = upsertSessionIntoProject(previousProject, upsert);
return updated === previousProject ? previousProject : updated;
});
};
if (!updatedSelectedSession) {
setSelectedSession(null);
}
}, [latestMessage, selectedProject, selectedSession, activeSessions, projects]);
return subscribe(handleEvent);
}, [subscribe]);
useEffect(() => {
return () => {
@@ -578,10 +635,12 @@ export function useProjectsState({
}
}
// Session id is in the URL but not yet present on any project payload (common
// right after `session_created` + navigate, before the next projects refresh).
// Without a `selectedSession`, chat state clears `currentSessionId` and the
// UI stops reading the session store even though messages stream under this id.
// Session id is in the URL but not yet present on any project payload
// (normal for a brand-new conversation: the composer allocates the id and
// navigates before the sidebar learns about the session via
// `session_upserted`). Without a `selectedSession`, chat state clears
// `currentSessionId` and the UI stops reading the session store even
// though messages stream under this id — so synthesize a placeholder.
if (selectedSession?.id === sessionId) {
return;
}
@@ -637,11 +696,6 @@ export function useProjectsState({
setActiveTab('chat');
}
const provider = localStorage.getItem('selected-provider') || 'claude';
if (provider === 'cursor') {
sessionStorage.setItem('cursorSessionId', session.id);
}
if (isMobile) {
// Sessions are tagged with the owning project's DB `projectId` when
// picked from the sidebar (see useSidebarController); compare against

View File

@@ -1,12 +1,5 @@
import { useCallback, useState } from 'react';
/**
* Map key for a request that is in flight before the provider has announced
* its real session id (a brand-new conversation). `session_created` migrates
* the entry to the concrete session id.
*/
export const PENDING_SESSION_ID = '__pending_session__';
export interface SessionActivity {
/** Provider-supplied status line; null renders the default activity label. */
statusText: string | null;
@@ -34,9 +27,9 @@ export type MarkSessionIdle = (
* Single source of truth for which sessions are actively processing a
* request. Everything the chat UI shows (activity indicator, abort
* availability, status text) is derived from this map; terminal events
* (`complete`, `error`, abort, an authoritative idle status reply) delete the
* entry atomically. The map also drives session protection: project refreshes
* are suppressed for sessions that have an entry here.
* (`complete`, abort, an authoritative idle subscribe ack) delete the entry
* atomically. Session ids are always concrete (allocated before the first
* send), so entries are keyed by real session ids only.
*/
export function useSessionProtection() {
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
@@ -82,9 +75,9 @@ export function useSessionProtection() {
return prev;
}
// Guard against stale `check-session-status` replies: if a new request
// started after the check was sent, the idle reply describes the older
// request and must not clear the newer one.
// Guard against stale `chat_subscribed` idle acks: if a new request
// started after the subscribe was sent, the idle ack describes the
// older request and must not clear the newer one.
if (opts?.ifStartedBefore !== undefined && existing.startedAt >= opts.ifStartedBefore) {
return prev;
}

View File

@@ -36,6 +36,12 @@ export interface NormalizedMessage {
timestamp: string;
provider: LLMProvider;
kind: MessageKind;
/**
* Per-run monotonic sequence number assigned by the backend to live
* websocket events. Used to compute `lastSeq` for `chat.subscribe` replay;
* REST history messages do not carry it.
*/
seq?: number;
// kind-specific fields (flat for simplicity)
role?: 'user' | 'assistant';
@@ -186,60 +192,6 @@ function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[
return dedupeAdjacentAssistantEchoes([...server, ...extra]);
}
function compareMessagesByTimestamp(left: NormalizedMessage, right: NormalizedMessage): number {
const leftTime = Date.parse(left.timestamp);
const rightTime = Date.parse(right.timestamp);
if (Number.isNaN(leftTime) || Number.isNaN(rightTime) || leftTime === rightTime) {
return 0;
}
return leftTime - rightTime;
}
function rewriteMessageSessionId(
msg: NormalizedMessage,
fromSessionId: string,
toSessionId: string,
): NormalizedMessage {
const streamingSourceId = `__streaming_${fromSessionId}`;
const nextId = msg.id === streamingSourceId ? `__streaming_${toSessionId}` : msg.id;
if (msg.sessionId === toSessionId && nextId === msg.id) {
return msg;
}
return {
...msg,
id: nextId,
sessionId: toSessionId,
};
}
function mergeMessagesById(
existing: NormalizedMessage[],
incoming: NormalizedMessage[],
): NormalizedMessage[] {
if (existing.length === 0) return incoming;
if (incoming.length === 0) return existing;
const merged = [...existing, ...incoming];
const deduped: NormalizedMessage[] = [];
const seen = new Set<string>();
for (const msg of merged) {
if (seen.has(msg.id)) {
continue;
}
seen.add(msg.id);
deduped.push(msg);
}
deduped.sort(compareMessagesByTimestamp);
return deduped;
}
/**
* Recompute slot.merged only when the input arrays have actually changed
* (by reference). Returns true if merged was recomputed.
@@ -264,64 +216,39 @@ const MAX_REALTIME_MESSAGES = 500;
export function useSessionStore() {
const storeRef = useRef(new Map<string, SessionSlot>());
const sessionAliasesRef = useRef(new Map<string, string>());
const activeSessionIdRef = useRef<string | null>(null);
// Bump to force re-render — only when the active session's data changes
// Bump to force re-render — only when the active session's data changes.
// Session ids are stable for the whole conversation lifetime (the backend
// allocates them before the first send), so slots are keyed directly with
// no alias/redirect indirection.
const [, setTick] = useState(0);
const notify = useCallback((sessionId: string) => {
const aliases = sessionAliasesRef.current;
let resolvedSessionId = sessionId;
const visited = new Set<string>();
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
visited.add(resolvedSessionId);
resolvedSessionId = aliases.get(resolvedSessionId)!;
}
if (resolvedSessionId === activeSessionIdRef.current) {
if (sessionId === activeSessionIdRef.current) {
setTick(n => n + 1);
}
}, []);
const resolveSessionId = useCallback((sessionId: string | null | undefined): string | null => {
if (!sessionId) {
return null;
}
const aliases = sessionAliasesRef.current;
let resolvedSessionId = sessionId;
const visited = new Set<string>();
while (aliases.has(resolvedSessionId) && !visited.has(resolvedSessionId)) {
visited.add(resolvedSessionId);
resolvedSessionId = aliases.get(resolvedSessionId)!;
}
return resolvedSessionId;
const setActiveSession = useCallback((sessionId: string | null) => {
activeSessionIdRef.current = sessionId;
}, []);
const setActiveSession = useCallback((sessionId: string | null) => {
activeSessionIdRef.current = resolveSessionId(sessionId);
}, [resolveSessionId]);
const getSlot = useCallback((sessionId: string): SessionSlot => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const store = storeRef.current;
if (!store.has(resolvedSessionId)) {
store.set(resolvedSessionId, createEmptySlot());
if (!store.has(sessionId)) {
store.set(sessionId, createEmptySlot());
}
return store.get(resolvedSessionId)!;
}, [resolveSessionId]);
return store.get(sessionId)!;
}, []);
const has = useCallback((sessionId: string) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
return storeRef.current.has(resolvedSessionId);
}, [resolveSessionId]);
return storeRef.current.has(sessionId);
}, []);
/**
* Fetch messages from the provider sessions endpoint and populate serverMessages.
*
* Provider and project metadata are resolved server-side from `sessionId`.
* The endpoint returns the standard `{ success, data }` envelope.
*/
const fetchFromServer = useCallback(async (
sessionId: string,
@@ -333,10 +260,9 @@ export function useSessionStore() {
offset?: number;
} = {},
) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const slot = getSlot(sessionId);
slot.status = 'loading';
notify(resolvedSessionId);
notify(sessionId);
try {
const params = new URLSearchParams();
@@ -346,14 +272,15 @@ export function useSessionStore() {
}
const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
const response = await authenticatedFetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const body = await response.json();
const data = body?.data ?? body;
const messages: NormalizedMessage[] = data.messages || [];
slot.serverMessages = messages;
@@ -367,15 +294,15 @@ export function useSessionStore() {
slot.tokenUsage = data.tokenUsage;
}
notify(resolvedSessionId);
notify(sessionId);
return slot;
} catch (error) {
console.error(`[SessionStore] fetch failed for ${resolvedSessionId}:`, error);
console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);
slot.status = 'error';
notify(resolvedSessionId);
notify(sessionId);
return slot;
}
}, [getSlot, notify, resolveSessionId]);
}, [getSlot, notify]);
/**
* Load older (paginated) messages and prepend to serverMessages.
@@ -389,8 +316,7 @@ export function useSessionStore() {
limit?: number;
} = {},
) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const slot = getSlot(sessionId);
if (!slot.hasMore) return slot;
const params = new URLSearchParams();
@@ -399,12 +325,13 @@ export function useSessionStore() {
params.append('offset', String(slot.offset));
const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;
try {
const response = await authenticatedFetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const body = await response.json();
const data = body?.data ?? body;
const olderMessages: NormalizedMessage[] = data.messages || [];
// Prepend older messages (they're earlier in the conversation)
@@ -412,45 +339,43 @@ export function useSessionStore() {
slot.hasMore = Boolean(data.hasMore);
slot.offset = slot.offset + olderMessages.length;
recomputeMergedIfNeeded(slot);
notify(resolvedSessionId);
notify(sessionId);
return slot;
} catch (error) {
console.error(`[SessionStore] fetchMore failed for ${resolvedSessionId}:`, error);
console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error);
return slot;
}
}, [getSlot, notify, resolveSessionId]);
}, [getSlot, notify]);
/**
* Append a realtime (WebSocket) message to the correct session slot.
* This works regardless of which session is actively viewed.
*/
const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const slot = getSlot(sessionId);
const normalizedMessage =
msg.sessionId === resolvedSessionId
msg.sessionId === sessionId
? msg
: { ...msg, sessionId: resolvedSessionId };
: { ...msg, sessionId };
let updated = [...slot.realtimeMessages, normalizedMessage];
if (updated.length > MAX_REALTIME_MESSAGES) {
updated = updated.slice(-MAX_REALTIME_MESSAGES);
}
slot.realtimeMessages = updated;
recomputeMergedIfNeeded(slot);
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
notify(sessionId);
}, [getSlot, notify]);
/**
* Append multiple realtime messages at once (batch).
*/
const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {
if (msgs.length === 0) return;
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const slot = getSlot(sessionId);
const normalizedMessages = msgs.map((msg) =>
msg.sessionId === resolvedSessionId
msg.sessionId === sessionId
? msg
: { ...msg, sessionId: resolvedSessionId },
: { ...msg, sessionId },
);
let updated = [...slot.realtimeMessages, ...normalizedMessages];
if (updated.length > MAX_REALTIME_MESSAGES) {
@@ -458,8 +383,8 @@ export function useSessionStore() {
}
slot.realtimeMessages = updated;
recomputeMergedIfNeeded(slot);
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
notify(sessionId);
}, [getSlot, notify]);
/**
* Re-fetch serverMessages from the provider sessions endpoint.
@@ -472,17 +397,14 @@ export function useSessionStore() {
projectPath?: string;
} = {},
) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const slot = getSlot(sessionId);
try {
const params = new URLSearchParams();
const qs = params.toString();
const url = `/api/providers/sessions/${encodeURIComponent(resolvedSessionId)}/messages${qs ? `?${qs}` : ''}`;
const url = `/api/providers/sessions/${encodeURIComponent(sessionId)}/messages`;
const response = await authenticatedFetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const body = await response.json();
const data = body?.data ?? body;
slot.serverMessages = data.messages || [];
slot.total = data.total ?? slot.serverMessages.length;
@@ -491,43 +413,40 @@ export function useSessionStore() {
// drop realtime messages that the server has caught up with to prevent unbounded growth.
slot.realtimeMessages = [];
recomputeMergedIfNeeded(slot);
notify(resolvedSessionId);
notify(sessionId);
} catch (error) {
console.error(`[SessionStore] refresh failed for ${resolvedSessionId}:`, error);
console.error(`[SessionStore] refresh failed for ${sessionId}:`, error);
}
}, [getSlot, notify, resolveSessionId]);
}, [getSlot, notify]);
/**
* Update session status.
*/
const setStatus = useCallback((sessionId: string, status: SessionStatus) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const slot = getSlot(sessionId);
slot.status = status;
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
notify(sessionId);
}, [getSlot, notify]);
/**
* Check if a session's data is stale (>30s old).
*/
const isStale = useCallback((sessionId: string) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
const slot = storeRef.current.get(sessionId);
if (!slot) return true;
return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS;
}, [resolveSessionId]);
}, []);
/**
* Update or create a streaming message (accumulated text so far).
* Uses a well-known ID so subsequent calls replace the same message.
*/
const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: LLMProvider) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = getSlot(resolvedSessionId);
const streamId = `__streaming_${resolvedSessionId}`;
const slot = getSlot(sessionId);
const streamId = `__streaming_${sessionId}`;
const msg: NormalizedMessage = {
id: streamId,
sessionId: resolvedSessionId,
sessionId,
timestamp: new Date().toISOString(),
provider: msgProvider,
kind: 'stream_delta',
@@ -541,18 +460,17 @@ export function useSessionStore() {
slot.realtimeMessages = [...slot.realtimeMessages, msg];
}
recomputeMergedIfNeeded(slot);
notify(resolvedSessionId);
}, [getSlot, notify, resolveSessionId]);
notify(sessionId);
}, [getSlot, notify]);
/**
* Finalize streaming: convert the streaming message to a regular text message.
* The well-known streaming ID is replaced with a unique text message ID.
*/
const finalizeStreaming = useCallback((sessionId: string) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
const slot = storeRef.current.get(sessionId);
if (!slot) return;
const streamId = `__streaming_${resolvedSessionId}`;
const streamId = `__streaming_${sessionId}`;
const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);
if (idx >= 0) {
const stream = slot.realtimeMessages[idx];
@@ -564,104 +482,35 @@ export function useSessionStore() {
role: 'assistant',
};
recomputeMergedIfNeeded(slot);
notify(resolvedSessionId);
notify(sessionId);
}
}, [notify, resolveSessionId]);
}, [notify]);
/**
* Clear realtime messages for a session (e.g., after stream completes and server fetch catches up).
*/
const clearRealtime = useCallback((sessionId: string) => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
const slot = storeRef.current.get(resolvedSessionId);
const slot = storeRef.current.get(sessionId);
if (slot) {
slot.realtimeMessages = [];
recomputeMergedIfNeeded(slot);
notify(resolvedSessionId);
notify(sessionId);
}
}, [notify, resolveSessionId]);
}, [notify]);
/**
* Get merged messages for a session (for rendering).
*/
const getMessages = useCallback((sessionId: string): NormalizedMessage[] => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
return storeRef.current.get(resolvedSessionId)?.merged ?? [];
}, [resolveSessionId]);
return storeRef.current.get(sessionId)?.merged ?? [];
}, []);
/**
* Get session slot (for status, pagination info, etc.).
*/
const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => {
const resolvedSessionId = resolveSessionId(sessionId) ?? sessionId;
return storeRef.current.get(resolvedSessionId);
}, [resolveSessionId]);
const replaceSessionId = useCallback((fromSessionId: string, toSessionId: string) => {
const resolvedFromSessionId = resolveSessionId(fromSessionId) ?? fromSessionId;
const resolvedToSessionId = resolveSessionId(toSessionId) ?? toSessionId;
if (resolvedFromSessionId === resolvedToSessionId) {
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
return;
}
const store = storeRef.current;
const sourceSlot = store.get(resolvedFromSessionId);
const targetSlot = store.get(resolvedToSessionId) ?? createEmptySlot();
if (sourceSlot) {
const migratedServerMessages = sourceSlot.serverMessages.map((msg) =>
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
);
const migratedRealtimeMessages = sourceSlot.realtimeMessages.map((msg) =>
rewriteMessageSessionId(msg, resolvedFromSessionId, resolvedToSessionId),
);
targetSlot.serverMessages = mergeMessagesById(targetSlot.serverMessages, migratedServerMessages);
targetSlot.realtimeMessages = mergeMessagesById(targetSlot.realtimeMessages, migratedRealtimeMessages);
if (targetSlot.realtimeMessages.length > MAX_REALTIME_MESSAGES) {
targetSlot.realtimeMessages = targetSlot.realtimeMessages.slice(-MAX_REALTIME_MESSAGES);
}
targetSlot.status =
sourceSlot.status === 'error'
? 'error'
: sourceSlot.status === 'streaming' || targetSlot.status === 'streaming'
? 'streaming'
: sourceSlot.status === 'loading' || targetSlot.status === 'loading'
? 'loading'
: targetSlot.status;
targetSlot.fetchedAt = Math.max(targetSlot.fetchedAt, sourceSlot.fetchedAt, Date.now());
targetSlot.total = Math.max(
targetSlot.total,
sourceSlot.total,
targetSlot.serverMessages.length,
targetSlot.realtimeMessages.length,
);
targetSlot.hasMore = targetSlot.hasMore || sourceSlot.hasMore;
targetSlot.offset = Math.max(targetSlot.offset, sourceSlot.offset);
targetSlot.tokenUsage = targetSlot.tokenUsage ?? sourceSlot.tokenUsage;
recomputeMergedIfNeeded(targetSlot);
store.set(resolvedToSessionId, targetSlot);
store.delete(resolvedFromSessionId);
}
sessionAliasesRef.current.set(resolvedFromSessionId, resolvedToSessionId);
sessionAliasesRef.current.set(fromSessionId, resolvedToSessionId);
for (const [aliasSessionId, targetSessionId] of sessionAliasesRef.current.entries()) {
if (targetSessionId === resolvedFromSessionId) {
sessionAliasesRef.current.set(aliasSessionId, resolvedToSessionId);
}
}
if (activeSessionIdRef.current === resolvedFromSessionId) {
activeSessionIdRef.current = resolvedToSessionId;
}
notify(resolvedToSessionId);
}, [notify, resolveSessionId]);
return storeRef.current.get(sessionId);
}, []);
return useMemo(() => ({
getSlot,
@@ -679,12 +528,11 @@ export function useSessionStore() {
clearRealtime,
getMessages,
getSessionSlot,
replaceSessionId,
}), [
getSlot, has, fetchFromServer, fetchMore,
appendRealtime, appendRealtimeBatch, refreshFromServer,
setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming,
clearRealtime, getMessages, getSessionSlot, replaceSessionId,
clearRealtime, getMessages, getSessionSlot,
]);
}

View File

@@ -70,32 +70,10 @@ export interface Project {
}
export interface LoadingProgress {
type?: 'loading_progress';
kind?: 'loading_progress';
phase?: string;
current: number;
total: number;
currentProject?: string;
[key: string]: unknown;
}
export interface ProjectsUpdatedMessage {
type: 'projects_updated';
projects: Project[];
updatedSessionId?: string;
updatedSessionIds?: string[];
watchProvider?: LLMProvider;
watchProviders?: LLMProvider[];
changeType?: 'add' | 'change';
changeTypes?: Array<'add' | 'change'>;
batched?: boolean;
[key: string]: unknown;
}
export interface LoadingProgressMessage extends LoadingProgress {
type: 'loading_progress';
}
export type AppSocketMessage =
| LoadingProgressMessage
| ProjectsUpdatedMessage
| { type?: string;[key: string]: unknown };