mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 01:22:06 +08:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user