mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-23 18:25:44 +08:00
Merge remote-tracking branch 'origin/feat/unify-websocket-2' into browser-use-independent
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -10,6 +10,33 @@ import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/Palett
|
||||
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
||||
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
||||
import { useProjectsState } from '../../hooks/useProjectsState';
|
||||
import { api } from '../../utils/api';
|
||||
|
||||
type RunningSessionApiItem = {
|
||||
sessionId?: unknown;
|
||||
startedAt?: unknown;
|
||||
statusText?: unknown;
|
||||
canInterrupt?: unknown;
|
||||
};
|
||||
|
||||
type RunningSessionsApiPayload = {
|
||||
data?: {
|
||||
sessions?: RunningSessionApiItem[];
|
||||
};
|
||||
};
|
||||
|
||||
const parseStartedAt = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
export default function AppContent() {
|
||||
return (
|
||||
@@ -24,16 +51,13 @@ 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 {
|
||||
activeSessions,
|
||||
processingSessions,
|
||||
markSessionAsActive,
|
||||
markSessionAsInactive,
|
||||
markSessionAsProcessing,
|
||||
markSessionAsNotProcessing,
|
||||
markSessionProcessing,
|
||||
markSessionIdle,
|
||||
syncProcessingSessions,
|
||||
} = useSessionProtection();
|
||||
|
||||
const {
|
||||
@@ -50,16 +74,64 @@ function AppContentInner() {
|
||||
setShowSettings,
|
||||
openSettings,
|
||||
refreshProjectsSilently,
|
||||
registerOptimisticSession,
|
||||
sidebarSharedProps,
|
||||
handleNewSession,
|
||||
} = useProjectsState({
|
||||
sessionId,
|
||||
navigate,
|
||||
latestMessage,
|
||||
subscribe,
|
||||
isMobile,
|
||||
activeSessions,
|
||||
activeSessions: processingSessions,
|
||||
});
|
||||
|
||||
const refreshRunningSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.runningSessions();
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as RunningSessionsApiPayload;
|
||||
const sessions = Array.isArray(payload.data?.sessions) ? payload.data.sessions : [];
|
||||
|
||||
syncProcessingSessions(
|
||||
sessions
|
||||
.map((session) => {
|
||||
if (typeof session.sessionId !== 'string' || !session.sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
startedAt: parseStartedAt(session.startedAt),
|
||||
statusText: typeof session.statusText === 'string' ? session.statusText : undefined,
|
||||
canInterrupt: typeof session.canInterrupt === 'boolean' ? session.canInterrupt : undefined,
|
||||
};
|
||||
})
|
||||
.filter((session): session is NonNullable<typeof session> => Boolean(session)),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[AppContent] Failed to sync running sessions:', error);
|
||||
}
|
||||
}, [syncProcessingSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRunningSessions();
|
||||
}, [refreshRunningSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (processingSessions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshRunningSessions();
|
||||
}, 5000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [processingSessions.size, refreshRunningSessions]);
|
||||
|
||||
usePaletteOpsRegister({
|
||||
openSettings,
|
||||
refreshProjects: refreshProjectsSilently,
|
||||
@@ -99,23 +171,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,
|
||||
@@ -180,19 +238,19 @@ function AppContentInner() {
|
||||
setActiveTab={setActiveTab}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
latestMessage={latestMessage}
|
||||
isMobile={isMobile}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
isLoading={isLoadingProjects}
|
||||
onInputFocusChange={setIsInputFocused}
|
||||
onSessionActive={markSessionAsActive}
|
||||
onSessionInactive={markSessionAsInactive}
|
||||
onSessionProcessing={markSessionAsProcessing}
|
||||
onSessionNotProcessing={markSessionAsNotProcessing}
|
||||
onSessionProcessing={markSessionProcessing}
|
||||
onSessionIdle={markSessionIdle}
|
||||
processingSessions={processingSessions}
|
||||
onNavigateToSession={(targetSessionId: string, options) =>
|
||||
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
||||
}
|
||||
onSessionEstablished={(targetSessionId, context) =>
|
||||
registerOptimisticSession({ sessionId: targetSessionId, ...context })
|
||||
}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
newSessionTrigger={newSessionTrigger}
|
||||
|
||||
@@ -12,12 +12,14 @@ import type {
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||
import { safeLocalStorage } from '../utils/chatStorage';
|
||||
import type {
|
||||
ChatMessage,
|
||||
PendingPermissionRequest,
|
||||
PermissionMode,
|
||||
SessionEstablishedContext,
|
||||
} from '../types/types';
|
||||
import type { Project, ProjectSession, LLMProvider, ProviderModelsCacheInfo } from '../../../types/app';
|
||||
import { escapeRegExp } from '../utils/chatFormatting';
|
||||
@@ -25,10 +27,6 @@ import { escapeRegExp } from '../utils/chatFormatting';
|
||||
import { useFileMentions } from './useFileMentions';
|
||||
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatComposerStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
@@ -46,17 +44,20 @@ interface UseChatComposerStateArgs {
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
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, context: SessionEstablishedContext) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
pendingViewSessionRef: { current: PendingViewSession | null };
|
||||
scrollToBottom: () => void;
|
||||
addMessage: (msg: ChatMessage) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
}
|
||||
@@ -177,17 +178,13 @@ export function useChatComposerState({
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionEstablished,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setIsUserScrolledUp,
|
||||
setPendingPermissionRequests,
|
||||
}: UseChatComposerStateArgs) {
|
||||
@@ -609,8 +606,54 @@ export function useChatComposerState({
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveSessionId =
|
||||
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||
|
||||
// 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, {
|
||||
provider,
|
||||
project: selectedProject,
|
||||
summary: sessionSummary,
|
||||
});
|
||||
}
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
type: 'user',
|
||||
@@ -620,27 +663,17 @@ export function useChatComposerState({
|
||||
};
|
||||
|
||||
addMessage(userMessage);
|
||||
setIsLoading(true); // Processing banner starts
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
// Mark this request as processing in the per-session activity map (the
|
||||
// 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,
|
||||
});
|
||||
|
||||
setIsUserScrolledUp(false);
|
||||
setTimeout(() => scrollToBottom(), 100);
|
||||
|
||||
if (!effectiveSessionId && !selectedSession?.id) {
|
||||
// This tracks only that a request is in flight before the provider has
|
||||
// emitted its real session id; routing still waits for session_created.
|
||||
pendingViewSessionRef.current = { startedAt: Date.now() };
|
||||
}
|
||||
if (effectiveSessionId) {
|
||||
onSessionActive?.(effectiveSessionId);
|
||||
onSessionProcessing?.(effectiveSessionId);
|
||||
}
|
||||
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey =
|
||||
@@ -669,87 +702,35 @@ export function useChatComposerState({
|
||||
};
|
||||
|
||||
const toolsSettings = getToolsSettings();
|
||||
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
|
||||
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
|
||||
const model =
|
||||
provider === 'cursor'
|
||||
? cursorModel
|
||||
: provider === 'codex'
|
||||
? codexModel
|
||||
: provider === 'gemini'
|
||||
? geminiModel
|
||||
: provider === 'opencode'
|
||||
? opencodeModel
|
||||
: claudeModel;
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
// 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 = '';
|
||||
@@ -776,19 +757,15 @@ export function useChatComposerState({
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
pendingViewSessionRef,
|
||||
onSessionEstablished,
|
||||
permissionMode,
|
||||
provider,
|
||||
resetCommandMenuState,
|
||||
scrollToBottom,
|
||||
selectedProject,
|
||||
sendMessage,
|
||||
setCanAbortSession,
|
||||
addMessage,
|
||||
setClaudeStatus,
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
slashCommands,
|
||||
],
|
||||
@@ -944,29 +921,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 }) => {
|
||||
@@ -991,7 +958,7 @@ export function useChatComposerState({
|
||||
|
||||
validIds.forEach((requestId) => {
|
||||
sendMessage({
|
||||
type: 'claude-permission-response',
|
||||
type: 'chat.permission-response',
|
||||
requestId,
|
||||
allow: Boolean(decision?.allow),
|
||||
updatedInput: decision?.updatedInput,
|
||||
@@ -1000,15 +967,11 @@ export function useChatComposerState({
|
||||
});
|
||||
});
|
||||
|
||||
setPendingPermissionRequests((previous) => {
|
||||
const next = previous.filter((request) => !validIds.includes(request.requestId));
|
||||
if (next.length === 0) {
|
||||
setClaudeStatus(null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setPendingPermissionRequests((previous) =>
|
||||
previous.filter((request) => !validIds.includes(request.requestId)),
|
||||
);
|
||||
},
|
||||
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
||||
[sendMessage, setPendingPermissionRequests],
|
||||
);
|
||||
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
@@ -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,73 +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 type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
||||
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest } from '../types/types';
|
||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
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;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
streamTimerRef: MutableRefObject<number | null>;
|
||||
accumulatedStreamRef: MutableRefObject<string>;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
||||
/**
|
||||
* 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;
|
||||
onWebSocketReconnect?: () => void;
|
||||
sessionStore: SessionStore;
|
||||
}
|
||||
@@ -76,324 +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,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
lastSeqRef,
|
||||
statusCheckSentAtRef,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
onSessionIdle,
|
||||
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 'session-status': {
|
||||
const statusSessionId = msg.sessionId;
|
||||
if (!statusSessionId) return;
|
||||
|
||||
const status = msg.status;
|
||||
if (status) {
|
||||
const statusInfo = {
|
||||
text: status.text || 'Working...',
|
||||
tokens: status.tokens || 0,
|
||||
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
||||
};
|
||||
setClaudeStatus(statusInfo);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(statusInfo.can_interrupt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy isProcessing format from check-session-status
|
||||
const isCurrentSession =
|
||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||
case 'chat_subscribed': {
|
||||
// Ack for chat.subscribe: authoritative processing state plus any
|
||||
// pending tool-permission prompts for the run.
|
||||
if (!sid) return;
|
||||
|
||||
if (msg.isProcessing) {
|
||||
onSessionActive?.(statusSessionId);
|
||||
onSessionProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
onSessionInactive?.(statusSessionId);
|
||||
onSessionNotProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
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) {
|
||||
console.log('Session created with ID:', newSessionId);
|
||||
console.log('Existing session ID:', 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);
|
||||
}
|
||||
pendingViewSessionRef.current = null;
|
||||
onSessionActive?.(newSessionId);
|
||||
onSessionProcessing?.(newSessionId);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
setPendingPermissionRequests([]);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
// --- 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 loading state above
|
||||
// The backend already sent any abort-related messages
|
||||
break;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (isVisibleSession) {
|
||||
setCurrentSessionId(actualSessionId);
|
||||
}
|
||||
|
||||
if (isVisibleSession) {
|
||||
onNavigateToSession?.(actualSessionId, { replace: true });
|
||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
onSessionInactive?.(sid);
|
||||
onSessionNotProcessing?.(sid);
|
||||
pendingViewSessionRef.current = null;
|
||||
break;
|
||||
}
|
||||
|
||||
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(),
|
||||
}];
|
||||
});
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
||||
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) {
|
||||
setClaudeStatus({
|
||||
text: msg.text,
|
||||
tokens: msg.tokens || 0,
|
||||
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
||||
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(),
|
||||
}];
|
||||
});
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(msg.canInterrupt !== false);
|
||||
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,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
lastSeqRef,
|
||||
statusCheckSentAtRef,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
onSessionIdle,
|
||||
onWebSocketReconnect,
|
||||
sessionStore,
|
||||
paletteOps,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||
import type { ChatMessage, Provider } from '../types/types';
|
||||
import type { ChatMessage } from '../types/types';
|
||||
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
||||
|
||||
import { normalizedToChatMessages } from './useChatMessages';
|
||||
@@ -12,10 +13,6 @@ import { normalizedToChatMessages } from './useChatMessages';
|
||||
const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
interface UseChatSessionStateArgs {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
@@ -24,9 +21,13 @@ interface UseChatSessionStateArgs {
|
||||
autoScrollToBottom?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
newSessionTrigger?: number;
|
||||
processingSessions?: Set<string>;
|
||||
processingSessions?: SessionActivityMap;
|
||||
onSessionIdle?: MarkSessionIdle;
|
||||
resetStreamingState: () => void;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -99,21 +100,20 @@ export function useChatSessionState({
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
processingSessions,
|
||||
onSessionIdle,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
statusCheckSentAtRef,
|
||||
lastSeqRef,
|
||||
sessionStore,
|
||||
}: UseChatSessionStateArgs) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||||
const [totalMessages, setTotalMessages] = useState(0);
|
||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
||||
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
||||
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
||||
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
||||
@@ -170,13 +170,8 @@ export function useChatSessionState({
|
||||
* - No coupling to unrelated external update signals.
|
||||
*/
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
setPendingUserMessage(null);
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
@@ -204,13 +199,30 @@ export function useChatSessionState({
|
||||
clearTimeout(loadAllFinishedTimerRef.current);
|
||||
loadAllFinishedTimerRef.current = null;
|
||||
}
|
||||
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
|
||||
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Derive processing state for the viewed session */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||
|
||||
// The activity indicator always reflects the latest status of the session
|
||||
// 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;
|
||||
|
||||
// Ref mirror so effects can read the latest map without re-running on
|
||||
// every activity transition.
|
||||
const processingSessionsRef = useRef(processingSessions);
|
||||
processingSessionsRef.current = processingSessions;
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Derive chatMessages from the store */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
||||
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
||||
|
||||
@@ -316,18 +328,12 @@ export function useChatSessionState({
|
||||
if (allMessagesLoadedRef.current) return false;
|
||||
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
|
||||
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
|
||||
isLoadingMoreRef.current = true;
|
||||
const previousScrollHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
|
||||
try {
|
||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
// DB-assigned projectId replaces the legacy folder-derived name.
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
});
|
||||
if (!slot || slot.serverMessages.length === 0) return false;
|
||||
@@ -429,19 +435,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 processing banner alive until complete/error.
|
||||
if (pendingViewSessionRef.current) {
|
||||
// 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();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
@@ -450,8 +452,7 @@ export function useChatSessionState({
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`;
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}`;
|
||||
|
||||
// Skip if already loaded and fresh
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
|
||||
@@ -461,9 +462,6 @@ export function useChatSessionState({
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
if (sessionChanged) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
}
|
||||
|
||||
// Reset pagination/scroll state
|
||||
@@ -482,17 +480,24 @@ export function useChatSessionState({
|
||||
|
||||
if (sessionChanged) {
|
||||
setTokenBudget(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
if (provider === 'cursor') {
|
||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||
}
|
||||
|
||||
// Check session status
|
||||
// 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) {
|
||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
sessions: [{
|
||||
sessionId: selectedSession.id,
|
||||
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
lastLoadedSessionKeyRef.current = sessionKey;
|
||||
@@ -500,9 +505,6 @@ export function useChatSessionState({
|
||||
// Fetch from server → store updates → chatMessages re-derives automatically
|
||||
setIsLoadingSessionMessages(true);
|
||||
sessionStore.fetchFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
offset: 0,
|
||||
}).then(slot => {
|
||||
@@ -516,11 +518,12 @@ export function useChatSessionState({
|
||||
setIsLoadingSessionMessages(false);
|
||||
});
|
||||
}, [
|
||||
pendingViewSessionRef,
|
||||
resetStreamingState,
|
||||
selectedProject,
|
||||
selectedSession?.id,
|
||||
sendMessage,
|
||||
statusCheckSentAtRef,
|
||||
lastSeqRef,
|
||||
ws,
|
||||
sessionStore,
|
||||
]);
|
||||
@@ -531,15 +534,9 @@ export function useChatSessionState({
|
||||
|
||||
const reloadExternalMessages = async () => {
|
||||
try {
|
||||
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
|
||||
// Skip store refresh during active streaming
|
||||
if (!isLoading) {
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
});
|
||||
if (!isProcessing) {
|
||||
await sessionStore.refreshFromServer(selectedSession.id);
|
||||
|
||||
if (Boolean(autoScrollToBottom) && isNearBottom()) {
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
@@ -559,7 +556,7 @@ export function useChatSessionState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
sessionStore,
|
||||
isLoading,
|
||||
isProcessing,
|
||||
]);
|
||||
|
||||
// Search navigation target
|
||||
@@ -585,13 +582,9 @@ export function useChatSessionState({
|
||||
|
||||
const scrollToTarget = async () => {
|
||||
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
try {
|
||||
// Load all messages into the store for search navigation
|
||||
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: null,
|
||||
offset: 0,
|
||||
});
|
||||
@@ -665,17 +658,10 @@ export function useChatSessionState({
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
// Token usage endpoint is now keyed by the DB projectId.
|
||||
const params = new URLSearchParams({ provider: sessionProvider });
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
|
||||
// The backend resolves the provider from the indexed session row.
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
setTokenBudget(await response.json());
|
||||
@@ -687,7 +673,7 @@ export function useChatSessionState({
|
||||
}
|
||||
};
|
||||
fetchInitialTokenUsage();
|
||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
}, [selectedProject, selectedSession?.id]);
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
||||
@@ -726,16 +712,6 @@ export function useChatSessionState({
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeViewSessionId = selectedSession?.id || currentSessionId;
|
||||
if (!activeViewSessionId || !processingSessions) return;
|
||||
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
|
||||
if (shouldBeProcessing && !isLoading) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
}
|
||||
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
|
||||
|
||||
// "Load all" overlay
|
||||
const prevLoadingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -757,8 +733,6 @@ export function useChatSessionState({
|
||||
const loadAllMessages = useCallback(async () => {
|
||||
if (!selectedSession || !selectedProject) return;
|
||||
if (isLoadingAllMessages) return;
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
|
||||
const requestSessionId = selectedSession.id;
|
||||
allMessagesLoadedRef.current = true;
|
||||
isLoadingMoreRef.current = true;
|
||||
@@ -771,9 +745,6 @@ export function useChatSessionState({
|
||||
|
||||
try {
|
||||
const slot = await sessionStore.fetchFromServer(requestSessionId, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: null,
|
||||
offset: 0,
|
||||
});
|
||||
@@ -817,16 +788,15 @@ export function useChatSessionState({
|
||||
addMessage,
|
||||
clearMessages,
|
||||
rewindMessages,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
sessionActivity,
|
||||
isProcessing,
|
||||
canAbortSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
isLoadingSessionMessages,
|
||||
isLoadingMoreMessages,
|
||||
hasMoreMessages,
|
||||
totalMessages,
|
||||
canAbortSession,
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
@@ -839,8 +809,6 @@ export function useChatSessionState({
|
||||
isLoadingAllMessages,
|
||||
loadAllJustFinished,
|
||||
showLoadAllOverlay,
|
||||
claudeStatus,
|
||||
setClaudeStatus,
|
||||
createDiff,
|
||||
scrollContainerRef,
|
||||
scrollToBottom,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type {
|
||||
MarkSessionIdle,
|
||||
MarkSessionProcessing,
|
||||
SessionActivityMap,
|
||||
} from '../../../hooks/useSessionProtection';
|
||||
|
||||
export type Provider = LLMProvider;
|
||||
|
||||
@@ -102,20 +107,24 @@ export type SessionNavigationOptions = {
|
||||
replace?: boolean;
|
||||
};
|
||||
|
||||
export type SessionEstablishedContext = {
|
||||
provider: LLMProvider;
|
||||
project: Project;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export interface ChatInterfaceProps {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: any;
|
||||
onFileOpen?: (filePath: string, diffInfo?: any) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionInactive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||
processingSessions?: Set<string>;
|
||||
onSessionProcessing?: MarkSessionProcessing;
|
||||
onSessionIdle?: MarkSessionIdle;
|
||||
processingSessions?: SessionActivityMap;
|
||||
onNavigateToSession?: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||
onShowSettings?: () => void;
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
|
||||
@@ -2,10 +2,10 @@ 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';
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
import { useChatProviderState } from '../hooks/useChatProviderState';
|
||||
import { useChatSessionState } from '../hooks/useChatSessionState';
|
||||
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
|
||||
@@ -17,24 +17,18 @@ import ChatComposer from './subcomponents/ChatComposer';
|
||||
import CommandResultModal from './subcomponents/CommandResultModal';
|
||||
|
||||
|
||||
type PendingViewSession = {
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
function ChatInterface({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
ws,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
onFileOpen,
|
||||
onInputFocusChange,
|
||||
onSessionActive,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onSessionIdle,
|
||||
processingSessions,
|
||||
onNavigateToSession,
|
||||
onSessionEstablished,
|
||||
onShowSettings,
|
||||
autoExpandTools,
|
||||
showRawParameters,
|
||||
@@ -46,12 +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('');
|
||||
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
|
||||
// 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) {
|
||||
@@ -92,16 +93,15 @@ function ChatInterface({
|
||||
const {
|
||||
chatMessages,
|
||||
addMessage,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
sessionActivity,
|
||||
isProcessing,
|
||||
canAbortSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
isLoadingSessionMessages,
|
||||
isLoadingMoreMessages,
|
||||
hasMoreMessages,
|
||||
totalMessages,
|
||||
canAbortSession,
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
@@ -114,8 +114,6 @@ function ChatInterface({
|
||||
isLoadingAllMessages,
|
||||
loadAllJustFinished,
|
||||
showLoadAllOverlay,
|
||||
claudeStatus,
|
||||
setClaudeStatus,
|
||||
createDiff,
|
||||
scrollContainerRef,
|
||||
scrollToBottom,
|
||||
@@ -130,11 +128,22 @@ function ChatInterface({
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
processingSessions,
|
||||
onSessionIdle,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
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<NonNullable<ChatInterfaceProps['onSessionEstablished']>>((sessionId, context) => {
|
||||
setCurrentSessionId(sessionId);
|
||||
onSessionEstablished?.(sessionId, context);
|
||||
onNavigateToSession?.(sessionId);
|
||||
}, [setCurrentSessionId, onSessionEstablished, onNavigateToSession]);
|
||||
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
@@ -191,66 +200,58 @@ function ChatInterface({
|
||||
codexModel,
|
||||
geminiModel,
|
||||
opencodeModel,
|
||||
isLoading,
|
||||
isLoading: isProcessing,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onSessionEstablished: handleSessionEstablished,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
pendingViewSessionRef,
|
||||
scrollToBottom,
|
||||
addMessage,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setIsUserScrolledUp,
|
||||
setPendingPermissionRequests,
|
||||
});
|
||||
|
||||
// On WebSocket reconnect, re-fetch the current session's messages from the server
|
||||
// so missed streaming events are shown. Also reset isLoading.
|
||||
// On WebSocket reconnect, re-fetch the current session's messages from the
|
||||
// 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 = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || providerVal) as LLMProvider,
|
||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
await sessionStore.refreshFromServer(selectedSession.id);
|
||||
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
sessions: [{
|
||||
sessionId: selectedSession.id,
|
||||
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
||||
}],
|
||||
});
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
|
||||
}, [selectedProject, selectedSession, sendMessage, sessionStore]);
|
||||
|
||||
useChatRealtimeHandlers({
|
||||
latestMessage,
|
||||
subscribe,
|
||||
provider,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamTimerRef,
|
||||
accumulatedStreamRef,
|
||||
onSessionInactive,
|
||||
onSessionActive,
|
||||
lastSeqRef,
|
||||
statusCheckSentAtRef,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onNavigateToSession,
|
||||
onSessionIdle,
|
||||
onWebSocketReconnect: handleWebSocketReconnect,
|
||||
sessionStore,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading || !canAbortSession) {
|
||||
if (!canAbortSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -267,7 +268,7 @@ function ChatInterface({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||
};
|
||||
}, [canAbortSession, handleAbortSession, isLoading]);
|
||||
}, [canAbortSession, handleAbortSession]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -314,6 +315,7 @@ function ChatInterface({
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
isLoadingSessionMessages={isLoadingSessionMessages}
|
||||
isProcessing={isProcessing}
|
||||
chatMessages={chatMessages}
|
||||
selectedSession={selectedSession}
|
||||
currentSessionId={currentSessionId}
|
||||
@@ -362,10 +364,9 @@ function ChatInterface({
|
||||
pendingPermissionRequests={pendingPermissionRequests}
|
||||
handlePermissionDecision={handlePermissionDecision}
|
||||
handleGrantToolPermission={handleGrantToolPermission}
|
||||
claudeStatus={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
activity={sessionActivity}
|
||||
isLoading={isProcessing}
|
||||
onAbortSession={handleAbortSession}
|
||||
provider={provider}
|
||||
permissionMode={permissionMode}
|
||||
onModeSwitch={cyclePermissionMode}
|
||||
tokenBudget={tokenBudget}
|
||||
|
||||
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
80
src/components/chat/view/subcomponents/ActivityIndicator.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Shimmer } from '../../../../shared/view/ui';
|
||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
|
||||
type ActivityIndicatorProps = {
|
||||
activity: SessionActivity | null;
|
||||
onAbort?: () => void;
|
||||
};
|
||||
|
||||
const ACTION_KEYS = [
|
||||
'claudeStatus.actions.thinking',
|
||||
'claudeStatus.actions.processing',
|
||||
'claudeStatus.actions.analyzing',
|
||||
'claudeStatus.actions.working',
|
||||
'claudeStatus.actions.computing',
|
||||
'claudeStatus.actions.reasoning',
|
||||
];
|
||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
|
||||
/**
|
||||
* Minimal response-in-progress indicator, in the spirit of the inline status
|
||||
* lines in Claude Code / Codex / OpenCode: a shimmering activity label, the
|
||||
* elapsed time, and an interrupt affordance. Rendered only while the viewed
|
||||
* session has an entry in the processing map; it disappears the instant that
|
||||
* entry is removed.
|
||||
*/
|
||||
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const startedAt = activity?.startedAt ?? null;
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt === null) return;
|
||||
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
|
||||
update();
|
||||
const timer = setInterval(update, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [startedAt]);
|
||||
|
||||
if (!activity) return null;
|
||||
|
||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
|
||||
.replace(/\.+$/, '');
|
||||
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
const seconds = elapsedSeconds % 60;
|
||||
const elapsedLabel = minutes < 1
|
||||
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
|
||||
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in mb-2 w-full duration-300">
|
||||
<div className="mx-auto flex max-w-4xl items-center gap-2 px-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
||||
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||
|
||||
{activity.canInterrupt && onAbort && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
|
||||
>
|
||||
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||
<rect x="5" y="5" width="14" height="14" rx="2" />
|
||||
</svg>
|
||||
<span>{t('claudeStatus.stop', { defaultValue: 'Stop' })}</span>
|
||||
<kbd className="hidden rounded border border-border/60 px-1 text-[10px] text-muted-foreground/70 sm:inline-block">
|
||||
esc
|
||||
</kbd>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
} from 'react';
|
||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputHeader,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
} from '../../../../shared/view/ui';
|
||||
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import TokenUsageSummary from './TokenUsageSummary';
|
||||
@@ -51,10 +52,9 @@ interface ChatComposerProps {
|
||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||
) => void;
|
||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
||||
activity: SessionActivity | null;
|
||||
isLoading: boolean;
|
||||
onAbortSession: () => void;
|
||||
provider: Provider | string;
|
||||
permissionMode: PermissionMode | string;
|
||||
onModeSwitch: () => void;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
@@ -105,10 +105,9 @@ export default function ChatComposer({
|
||||
pendingPermissionRequests,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
claudeStatus,
|
||||
activity,
|
||||
isLoading,
|
||||
onAbortSession,
|
||||
provider,
|
||||
permissionMode,
|
||||
onModeSwitch,
|
||||
tokenBudget,
|
||||
@@ -173,12 +172,7 @@ export default function ChatComposer({
|
||||
return (
|
||||
<div className="flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6">
|
||||
{!hasPendingPermissions && (
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbort={onAbortSession}
|
||||
provider={provider}
|
||||
/>
|
||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||
)}
|
||||
|
||||
{pendingPermissionRequests.length > 0 && (
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ChatMessagesPaneProps {
|
||||
onWheel: () => void;
|
||||
onTouchMove: () => void;
|
||||
isLoadingSessionMessages: boolean;
|
||||
/** True while the viewed session has an active provider run in flight. */
|
||||
isProcessing?: boolean;
|
||||
chatMessages: ChatMessage[];
|
||||
selectedSession: ProjectSession | null;
|
||||
currentSessionId: string | null;
|
||||
@@ -68,6 +70,7 @@ export default function ChatMessagesPane({
|
||||
onWheel,
|
||||
onTouchMove,
|
||||
isLoadingSessionMessages,
|
||||
isProcessing = false,
|
||||
chatMessages,
|
||||
selectedSession,
|
||||
currentSessionId,
|
||||
@@ -147,7 +150,7 @@ export default function ChatMessagesPane({
|
||||
onTouchMove={onTouchMove}
|
||||
className="relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4"
|
||||
>
|
||||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
||||
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (
|
||||
<div className="mt-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400" />
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
|
||||
type ClaudeStatusProps = {
|
||||
status: {
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
can_interrupt?: boolean;
|
||||
} | null;
|
||||
onAbort?: () => void;
|
||||
isLoading: boolean;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const ACTION_KEYS = [
|
||||
'claudeStatus.actions.thinking',
|
||||
'claudeStatus.actions.processing',
|
||||
'claudeStatus.actions.analyzing',
|
||||
'claudeStatus.actions.working',
|
||||
'claudeStatus.actions.computing',
|
||||
'claudeStatus.actions.reasoning',
|
||||
];
|
||||
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
|
||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
claude: 'messageTypes.claude',
|
||||
codex: 'messageTypes.codex',
|
||||
cursor: 'messageTypes.cursor',
|
||||
gemini: 'messageTypes.gemini',
|
||||
opencode: 'messageTypes.opencode',
|
||||
};
|
||||
|
||||
function formatElapsedTime(totalSeconds: number) {
|
||||
const mins = Math.floor(totalSeconds / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
return mins < 1 ? `${secs}s` : `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
export default function ClaudeStatus({
|
||||
status,
|
||||
onAbort,
|
||||
isLoading,
|
||||
provider = 'claude',
|
||||
}: ClaudeStatusProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
return;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 1000));
|
||||
}, 1000);
|
||||
const dotTimer = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearInterval(dotTimer);
|
||||
};
|
||||
}, [isLoading]);
|
||||
|
||||
if (!isLoading && !status) return null;
|
||||
|
||||
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
|
||||
const statusText = (status?.text || actionWords[Math.floor(elapsedTime / 3) % actionWords.length]).replace(/[.]+$/, '');
|
||||
|
||||
const providerLabel = t(PROVIDER_LABEL_KEYS[provider] || 'claudeStatus.providers.assistant', { defaultValue: 'Assistant' });
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 mb-3 w-full duration-500">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between gap-3 overflow-hidden rounded-full border border-border/50 bg-slate-100 px-3 py-1.5 shadow-sm backdrop-blur-md dark:bg-slate-900">
|
||||
|
||||
{/* Left Side: Identity & Status */}
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/20 ring-1 ring-primary/10">
|
||||
<SessionProviderLogo provider={provider} className="h-3.5 w-3.5" />
|
||||
{isLoading && (
|
||||
<span className="absolute inset-0 animate-pulse rounded-full ring-2 ring-emerald-500/20" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col sm:flex-row sm:items-center sm:gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70">
|
||||
{providerLabel}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", isLoading ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
|
||||
<p className="truncate text-xs font-medium text-foreground">
|
||||
{statusText}<span className="inline-block w-4 text-primary">{isLoading ? dots : ''}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Metrics & Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && status?.can_interrupt !== false && onAbort && (
|
||||
<>
|
||||
<div className="hidden items-center rounded-md bg-muted/50 px-2 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground sm:flex">
|
||||
{formatElapsedTime(elapsedTime)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="group flex items-center gap-1.5 rounded-full bg-destructive/10 px-2.5 py-1 text-[10px] font-bold text-destructive transition-all hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<svg className="h-3 w-3 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M6 6h12v12H6z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">STOP</span>
|
||||
<kbd className="hidden rounded bg-black/10 px-1 text-[9px] group-hover:bg-white/20 sm:block">
|
||||
ESC
|
||||
</kbd>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,10 +11,6 @@ export type SessionResult = {
|
||||
|
||||
interface SessionsResponse {
|
||||
sessions?: ProjectSession[];
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
}
|
||||
|
||||
export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
|
||||
@@ -29,17 +25,10 @@ export function useSessionsSource(projectId: string | undefined, enabled: boolea
|
||||
);
|
||||
},
|
||||
parse: (data) => {
|
||||
const all: ProjectSession[] = [
|
||||
...(data.sessions ?? []),
|
||||
...(data.cursorSessions ?? []),
|
||||
...(data.codexSessions ?? []),
|
||||
...(data.geminiSessions ?? []),
|
||||
...(data.opencodeSessions ?? []),
|
||||
];
|
||||
return all.map<SessionResult>((s) => ({
|
||||
return (data.sessions ?? []).map<SessionResult>((s) => ({
|
||||
id: s.id,
|
||||
label: (s.title || s.summary || s.name || s.id) as string,
|
||||
provider: s.__provider,
|
||||
provider: (s.__provider || s.provider) as LLMProvider | undefined,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
type ClaudeLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => {
|
||||
return (
|
||||
<img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} />
|
||||
);
|
||||
};
|
||||
const ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => (
|
||||
<svg
|
||||
viewBox="0 0 512 509.64"
|
||||
role="img"
|
||||
aria-label="Claude"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#D77655"
|
||||
d="M115.612 0h280.775C459.974 0 512 52.026 512 115.612v278.415c0 63.587-52.026 115.612-115.613 115.612H115.612C52.026 509.639 0 457.614 0 394.027V115.612C0 52.026 52.026 0 115.612 0z"
|
||||
/>
|
||||
<path
|
||||
fill="#FCF2EE"
|
||||
fillRule="nonzero"
|
||||
d="M142.27 316.619l73.655-41.326 1.238-3.589-1.238-1.996-3.589-.001-12.31-.759-42.084-1.138-36.498-1.516-35.361-1.896-8.897-1.895-8.34-10.995.859-5.484 7.482-5.03 10.717.935 23.683 1.617 35.537 2.452 25.782 1.517 38.193 3.968h6.064l.86-2.451-2.073-1.517-1.618-1.517-36.776-24.922-39.81-26.338-20.852-15.166-11.273-7.683-5.687-7.204-2.451-15.721 10.237-11.273 13.75.935 3.513.936 13.928 10.716 29.749 23.027 38.848 28.612 5.687 4.727 2.275-1.617.278-1.138-2.553-4.271-21.13-38.193-22.546-38.848-10.035-16.101-2.654-9.655c-.935-3.968-1.617-7.304-1.617-11.374l11.652-15.823 6.445-2.073 15.545 2.073 6.547 5.687 9.655 22.092 15.646 34.78 24.265 47.291 7.103 14.028 3.791 12.992 1.416 3.968 2.449-.001v-2.275l1.997-26.641 3.69-32.707 3.589-42.084 1.239-11.854 5.863-14.206 11.652-7.683 9.099 4.348 7.482 10.716-1.036 6.926-4.449 28.915-8.72 45.294-5.687 30.331h3.313l3.792-3.791 15.342-20.372 25.782-32.227 11.374-12.789 13.27-14.129 8.517-6.724 16.1-.001 11.854 17.617-5.307 18.199-16.581 21.029-13.75 17.819-19.716 26.54-12.309 21.231 1.138 1.694 2.932-.278 44.536-9.479 24.062-4.347 28.714-4.928 12.992 6.066 1.416 6.167-5.106 12.613-30.71 7.583-36.018 7.204-53.636 12.689-.657.48.758.935 24.164 2.275 10.337.556h25.301l47.114 3.514 12.309 8.139 7.381 9.959-1.238 7.583-18.957 9.655-25.579-6.066-59.702-14.205-20.474-5.106-2.83-.001v1.694l17.061 16.682 31.266 28.233 39.152 36.397 1.997 8.999-5.03 7.102-5.307-.758-34.401-25.883-13.27-11.651-30.053-25.302-1.996-.001v2.654l6.926 10.136 36.574 54.975 1.895 16.859-2.653 5.485-9.479 3.311-10.414-1.895-21.408-30.054-22.092-33.844-17.819-30.331-2.173 1.238-10.515 113.261-4.929 5.788-11.374 4.348-9.478-7.204-5.03-11.652 5.03-23.027 6.066-30.052 4.928-23.886 4.449-29.674 2.654-9.858-.177-.657-2.173.278-22.37 30.71-34.021 45.977-26.919 28.815-6.445 2.553-11.173-5.789 1.037-10.337 6.243-9.2 37.257-47.392 22.47-29.371 14.508-16.961-.101-2.451h-.859l-98.954 64.251-17.618 2.275-7.583-7.103.936-11.652 3.589-3.791 29.749-20.474-.101.102.024.101z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ClaudeLogo;
|
||||
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
type CodexLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={isDarkMode ? "/icons/codex-white.svg" : "/icons/codex.svg"}
|
||||
alt="Codex"
|
||||
className={className}
|
||||
const CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => (
|
||||
<svg
|
||||
viewBox="100 100 520 520"
|
||||
role="img"
|
||||
aria-label="Codex"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M304.246 294.611V249.028C304.246 245.189 305.687 242.309 309.044 240.392L400.692 187.612C413.167 180.415 428.042 177.058 443.394 177.058C500.971 177.058 537.44 221.682 537.44 269.182C537.44 272.54 537.44 276.379 536.959 280.218L441.954 224.558C436.197 221.201 430.437 221.201 424.68 224.558L304.246 294.611ZM518.245 472.145V363.224C518.245 356.505 515.364 351.707 509.608 348.349L389.174 278.296L428.519 255.743C431.877 253.826 434.757 253.826 438.115 255.743L529.762 308.523C556.154 323.879 573.905 356.505 573.905 388.171C573.905 424.636 552.315 458.225 518.245 472.141V472.145ZM275.937 376.182L236.592 353.152C233.235 351.235 231.794 348.354 231.794 344.515V238.956C231.794 187.617 271.139 148.749 324.4 148.749C344.555 148.749 363.264 155.468 379.102 167.463L284.578 222.164C278.822 225.521 275.942 230.319 275.942 237.039V376.186L275.937 376.182ZM360.626 425.122L304.246 393.455V326.283L360.626 294.616L417.002 326.283V393.455L360.626 425.122ZM396.852 570.989C376.698 570.989 357.989 564.27 342.151 552.276L436.674 497.574C442.431 494.217 445.311 489.419 445.311 482.699V343.552L485.138 366.582C488.495 368.499 489.936 371.379 489.936 375.219V480.778C489.936 532.117 450.109 570.985 396.852 570.985V570.989ZM283.134 463.99L191.486 411.211C165.094 395.854 147.343 363.229 147.343 331.562C147.343 294.616 169.415 261.509 203.48 247.593V356.991C203.48 363.71 206.361 368.508 212.117 371.866L332.074 441.437L292.729 463.99C289.372 465.907 286.491 465.907 283.134 463.99ZM277.859 542.68C223.639 542.68 183.813 501.895 183.813 451.514C183.813 447.675 184.294 443.836 184.771 439.997L279.295 494.698C285.051 498.056 290.812 498.056 296.568 494.698L417.002 425.127V470.71C417.002 474.549 415.562 477.429 412.204 479.346L320.557 532.126C308.081 539.323 293.206 542.68 277.854 542.68H277.859ZM396.852 599.776C454.911 599.776 503.37 558.513 514.41 503.812C568.149 489.896 602.696 439.515 602.696 388.176C602.696 354.587 588.303 321.962 562.392 298.45C564.791 288.373 566.231 278.296 566.231 268.224C566.231 199.611 510.571 148.267 446.274 148.267C433.322 148.267 420.846 150.184 408.37 154.505C386.775 133.392 357.026 119.958 324.4 119.958C266.342 119.958 217.883 161.22 206.843 215.921C153.104 229.837 118.557 280.218 118.557 331.557C118.557 365.146 132.95 397.771 158.861 421.283C156.462 431.36 155.022 441.437 155.022 451.51C155.022 520.123 210.682 571.466 274.978 571.466C287.931 571.466 300.407 569.549 312.883 565.228C334.473 586.341 364.222 599.776 396.852 599.776Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
};
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default CodexLogo;
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
type CursorLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<img
|
||||
src={isDarkMode ? "/icons/cursor-white.svg" : "/icons/cursor.svg"}
|
||||
alt="Cursor"
|
||||
className={className}
|
||||
const CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Cursor"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.925 24l10.425-6-10.425-6L1.5 18l10.425 6z"
|
||||
fill="currentColor"
|
||||
opacity=".39"
|
||||
/>
|
||||
);
|
||||
};
|
||||
<path d="M22.35 18V6L11.925 0v12l10.425 6z" fill="currentColor" opacity=".8" />
|
||||
<path d="M11.925 0L1.5 6v12l10.425-6V0z" fill="currentColor" opacity=".6" />
|
||||
<path d="M22.35 6L11.925 24V12L22.35 6z" fill="currentColor" opacity=".72" />
|
||||
<path d="M22.35 6l-10.425 6L1.5 6h20.85z" fill="currentColor" opacity=".95" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default CursorLogo;
|
||||
|
||||
@@ -1,7 +1,263 @@
|
||||
const GeminiLogo = ({className = 'w-5 h-5'}) => {
|
||||
import { useId } from 'react';
|
||||
|
||||
type GeminiLogoProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const GeminiLogo = ({ className = 'w-5 h-5' }: GeminiLogoProps) => {
|
||||
const id = useId().replace(/:/g, '');
|
||||
const maskId = `${id}-gemini-mask`;
|
||||
const gradientId = `${id}-gemini-gradient`;
|
||||
const filterIds = Array.from({ length: 11 }, (_, index) => `${id}-gemini-filter-${index}`);
|
||||
|
||||
return (
|
||||
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
|
||||
<svg
|
||||
viewBox="0 0 65 65"
|
||||
role="img"
|
||||
aria-label="Gemini"
|
||||
className={className}
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id={maskId}
|
||||
style={{ maskType: 'alpha' }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="65"
|
||||
height="65"
|
||||
>
|
||||
<path
|
||||
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
|
||||
fill="#000"
|
||||
/>
|
||||
<path
|
||||
d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z"
|
||||
fill={`url(#${gradientId})`}
|
||||
/>
|
||||
</mask>
|
||||
<g mask={`url(#${maskId})`}>
|
||||
<g filter={`url(#${filterIds[0]})`}>
|
||||
<path
|
||||
d="M-5.859 50.734c7.498 2.663 16.116-2.33 19.249-11.152 3.133-8.821-.406-18.131-7.904-20.794-7.498-2.663-16.116 2.33-19.25 11.151-3.132 8.822.407 18.132 7.905 20.795z"
|
||||
fill="#FFE432"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[1]})`}>
|
||||
<path
|
||||
d="M27.433 21.649c10.3 0 18.651-8.535 18.651-19.062 0-10.528-8.35-19.062-18.651-19.062S8.78-7.94 8.78 2.587c0 10.527 8.35 19.062 18.652 19.062z"
|
||||
fill="#FC413D"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[2]})`}>
|
||||
<path
|
||||
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
|
||||
fill="#00B95C"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[3]})`}>
|
||||
<path
|
||||
d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z"
|
||||
fill="#00B95C"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[4]})`}>
|
||||
<path
|
||||
d="M30.954 74.181c9.014-5.485 11.427-17.976 5.389-27.9-6.038-9.925-18.241-13.524-27.256-8.04-9.015 5.486-11.428 17.977-5.39 27.902 6.04 9.924 18.242 13.523 27.257 8.038z"
|
||||
fill="#00B95C"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[5]})`}>
|
||||
<path
|
||||
d="M67.391 42.993c10.132 0 18.346-7.91 18.346-17.666 0-9.757-8.214-17.667-18.346-17.667s-18.346 7.91-18.346 17.667c0 9.757 8.214 17.666 18.346 17.666z"
|
||||
fill="#3186FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[6]})`}>
|
||||
<path
|
||||
d="M-13.065 40.944c9.33 7.094 22.959 4.869 30.442-4.972 7.483-9.84 5.987-23.569-3.343-30.663C4.704-1.786-8.924.439-16.408 10.28c-7.483 9.84-5.986 23.57 3.343 30.664z"
|
||||
fill="#FBBC04"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[7]})`}>
|
||||
<path
|
||||
d="M34.74 51.43c11.135 7.656 25.896 5.524 32.968-4.764 7.073-10.287 3.779-24.832-7.357-32.488C49.215 6.52 34.455 8.654 27.382 18.94c-7.072 10.288-3.779 24.833 7.357 32.49z"
|
||||
fill="#3186FF"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[8]})`}>
|
||||
<path
|
||||
d="M54.984-2.336c2.833 3.852-.808 11.34-8.131 16.727-7.324 5.387-15.557 6.631-18.39 2.78-2.833-3.853.807-11.342 8.13-16.728 7.324-5.387 15.558-6.631 18.39-2.78z"
|
||||
fill="#749BFF"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[9]})`}>
|
||||
<path
|
||||
d="M31.727 16.104C43.053 5.598 46.94-8.626 40.41-15.666c-6.53-7.04-21.006-4.232-32.332 6.274s-15.214 24.73-8.683 31.77c6.53 7.04 21.006 4.232 32.332-6.274z"
|
||||
fill="#FC413D"
|
||||
/>
|
||||
</g>
|
||||
<g filter={`url(#${filterIds[10]})`}>
|
||||
<path
|
||||
d="M8.51 53.838c6.732 4.818 14.46 5.55 17.262 1.636 2.802-3.915-.384-10.994-7.116-15.812-6.731-4.818-14.46-5.55-17.261-1.636-2.802 3.915.383 10.994 7.115 15.812z"
|
||||
fill="#FFEE48"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id={filterIds[0]}
|
||||
x="-19.824"
|
||||
y="13.152"
|
||||
width="39.274"
|
||||
height="43.217"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="2.46" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[1]}
|
||||
x="-15.001"
|
||||
y="-40.257"
|
||||
width="84.868"
|
||||
height="85.688"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="11.891" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[2]}
|
||||
x="-20.776"
|
||||
y="11.927"
|
||||
width="79.454"
|
||||
height="90.916"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[3]}
|
||||
x="-20.776"
|
||||
y="11.927"
|
||||
width="79.454"
|
||||
height="90.916"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[4]}
|
||||
x="-19.845"
|
||||
y="15.459"
|
||||
width="79.731"
|
||||
height="81.505"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[5]}
|
||||
x="29.832"
|
||||
y="-11.552"
|
||||
width="75.117"
|
||||
height="73.758"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="9.606" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[6]}
|
||||
x="-38.583"
|
||||
y="-16.253"
|
||||
width="78.135"
|
||||
height="78.758"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="8.706" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[7]}
|
||||
x="8.107"
|
||||
y="-5.966"
|
||||
width="78.877"
|
||||
height="77.539"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="7.775" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[8]}
|
||||
x="13.587"
|
||||
y="-18.488"
|
||||
width="56.272"
|
||||
height="51.81"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="6.957" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[9]}
|
||||
x="-15.526"
|
||||
y="-31.297"
|
||||
width="70.856"
|
||||
height="69.306"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="5.876" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<filter
|
||||
id={filterIds[10]}
|
||||
x="-14.168"
|
||||
y="20.964"
|
||||
width="55.501"
|
||||
height="51.571"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="7.273" result="effect1_foregroundBlur_2001_67" />
|
||||
</filter>
|
||||
<linearGradient id={gradientId} x1="18.447" x2="52.153" y1="43.42" y2="15.004" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#4893FC" />
|
||||
<stop offset=".27" stopColor="#4893FC" />
|
||||
<stop offset=".777" stopColor="#969DFF" />
|
||||
<stop offset="1" stopColor="#BD99FE" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeminiLogo;
|
||||
export default GeminiLogo;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { AppTab, Project, ProjectSession } from '../../../types/app';
|
||||
import type { SessionNavigationOptions } from '../../chat/types/types';
|
||||
|
||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
||||
import type {
|
||||
MarkSessionIdle,
|
||||
MarkSessionProcessing,
|
||||
SessionActivityMap,
|
||||
} from '../../../hooks/useSessionProtection';
|
||||
import type { SessionEstablishedContext, SessionNavigationOptions } from '../../chat/types/types';
|
||||
|
||||
export type TaskMasterTask = {
|
||||
id: string | number;
|
||||
@@ -41,17 +44,15 @@ export type MainContentProps = {
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: unknown;
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
isLoading: boolean;
|
||||
onInputFocusChange: (focused: boolean) => void;
|
||||
onSessionActive: SessionLifecycleHandler;
|
||||
onSessionInactive: SessionLifecycleHandler;
|
||||
onSessionProcessing: SessionLifecycleHandler;
|
||||
onSessionNotProcessing: SessionLifecycleHandler;
|
||||
processingSessions: Set<string>;
|
||||
onSessionProcessing: MarkSessionProcessing;
|
||||
onSessionIdle: MarkSessionIdle;
|
||||
processingSessions: SessionActivityMap;
|
||||
onNavigateToSession: (targetSessionId: string, options?: SessionNavigationOptions) => void;
|
||||
onSessionEstablished: (sessionId: string, context: SessionEstablishedContext) => void;
|
||||
onShowSettings: () => void;
|
||||
externalMessageUpdate: number;
|
||||
newSessionTrigger: number;
|
||||
|
||||
@@ -38,17 +38,15 @@ function MainContent({
|
||||
setActiveTab,
|
||||
ws,
|
||||
sendMessage,
|
||||
latestMessage,
|
||||
isMobile,
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
onSessionActive,
|
||||
onSessionInactive,
|
||||
onSessionProcessing,
|
||||
onSessionNotProcessing,
|
||||
onSessionIdle,
|
||||
processingSessions,
|
||||
onNavigateToSession,
|
||||
onSessionEstablished,
|
||||
onShowSettings,
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
@@ -129,15 +127,13 @@ function MainContent({
|
||||
selectedSession={selectedSession}
|
||||
ws={ws}
|
||||
sendMessage={sendMessage}
|
||||
latestMessage={latestMessage}
|
||||
onFileOpen={handleFileOpen}
|
||||
onInputFocusChange={onInputFocusChange}
|
||||
onSessionActive={onSessionActive}
|
||||
onSessionInactive={onSessionInactive}
|
||||
onSessionProcessing={onSessionProcessing}
|
||||
onSessionNotProcessing={onSessionNotProcessing}
|
||||
onSessionIdle={onSessionIdle}
|
||||
processingSessions={processingSessions}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onSessionEstablished={onSessionEstablished}
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
||||
import { api } from '../../../utils/api';
|
||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
import type {
|
||||
ArchivedProjectListItem,
|
||||
ArchivedSessionListItem,
|
||||
@@ -81,6 +82,7 @@ type UseSidebarControllerArgs = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
t: TFunction;
|
||||
@@ -100,6 +102,7 @@ export function useSidebarController({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession: _selectedSession,
|
||||
activeSessions,
|
||||
isLoading,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -146,6 +149,8 @@ export function useSidebarController({
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
|
||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||
const activeSessionIds = useMemo(() => new Set(activeSessions.keys()), [activeSessions]);
|
||||
const runningSessionsCount = activeSessionIds.size;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
@@ -582,9 +587,35 @@ export function useSidebarController({
|
||||
[projectSortOrder, projectsWithResolvedStarState],
|
||||
);
|
||||
|
||||
const runningProjects = useMemo(() => {
|
||||
if (activeSessionIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortedProjects.reduce<Project[]>((acc, project) => {
|
||||
const sessions = (project.sessions ?? []).filter((session) => activeSessionIds.has(String(session.id)));
|
||||
const runningCount = sessions.length;
|
||||
|
||||
if (runningCount === 0) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
...project,
|
||||
sessions,
|
||||
sessionMeta: {
|
||||
...project.sessionMeta,
|
||||
total: runningCount,
|
||||
hasMore: false,
|
||||
},
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}, [activeSessionIds, sortedProjects]);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => filterProjects(sortedProjects, debouncedSearchQuery),
|
||||
[debouncedSearchQuery, sortedProjects],
|
||||
() => filterProjects(searchMode === 'running' ? runningProjects : sortedProjects, debouncedSearchQuery),
|
||||
[debouncedSearchQuery, runningProjects, searchMode, sortedProjects],
|
||||
);
|
||||
|
||||
const filteredArchivedSessions = useMemo(() => {
|
||||
@@ -914,6 +945,7 @@ export function useSidebarController({
|
||||
sessionDeleteConfirmation,
|
||||
showVersionModal,
|
||||
filteredProjects,
|
||||
runningSessionsCount,
|
||||
archivedProjects: filteredArchivedProjects,
|
||||
archivedSessions: filteredArchivedSessions,
|
||||
archivedSessionsCount: archivedProjects.length + archivedSessions.length,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../hooks/useSessionProtection';
|
||||
|
||||
export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'archived';
|
||||
export type SidebarSearchMode = 'projects' | 'conversations' | 'running' | 'archived';
|
||||
export type ArchivedProjectListItem = Project & { isArchived: true };
|
||||
|
||||
export type SessionWithProvider = ProjectSession & {
|
||||
@@ -40,6 +41,7 @@ export type SidebarProps = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeSessions: SessionActivityMap;
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
@@ -59,10 +61,6 @@ export type SidebarProps = {
|
||||
};
|
||||
|
||||
export type SessionViewModel = {
|
||||
isCursorSession: boolean;
|
||||
isCodexSession: boolean;
|
||||
isGeminiSession: boolean;
|
||||
isOpenCodeSession: boolean;
|
||||
isActive: boolean;
|
||||
sessionName: string;
|
||||
sessionTime: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { LLMProvider, Project, ProjectSession } from '../../../types/app';
|
||||
import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types';
|
||||
|
||||
export const readProjectSortOrder = (): ProjectSortOrder => {
|
||||
@@ -61,6 +61,13 @@ const getUpdatedTimestamp = (session: SessionWithProvider): string => {
|
||||
return String(session.lastActivity || '');
|
||||
};
|
||||
|
||||
const getSessionProvider = (session: ProjectSession): LLMProvider => {
|
||||
const provider = session.__provider ?? session.provider;
|
||||
return typeof provider === 'string' && provider.trim()
|
||||
? provider as LLMProvider
|
||||
: 'claude';
|
||||
};
|
||||
|
||||
export const getSessionDate = (session: SessionWithProvider): Date => {
|
||||
return new Date(getUpdatedTimestamp(session) || getCreatedTimestamp(session) || 0);
|
||||
};
|
||||
@@ -82,10 +89,6 @@ export const createSessionViewModel = (
|
||||
const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));
|
||||
|
||||
return {
|
||||
isCursorSession: session.__provider === 'cursor',
|
||||
isCodexSession: session.__provider === 'codex',
|
||||
isGeminiSession: session.__provider === 'gemini',
|
||||
isOpenCodeSession: session.__provider === 'opencode',
|
||||
isActive: diffInMinutes < 10,
|
||||
sessionName: getSessionName(session, t),
|
||||
sessionTime: getSessionTime(session),
|
||||
@@ -94,32 +97,10 @@ export const createSessionViewModel = (
|
||||
};
|
||||
|
||||
export const getAllSessions = (project: Project): SessionWithProvider[] => {
|
||||
const claudeSessions = [...(project.sessions || [])].map((session) => ({
|
||||
return (project.sessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'claude' as const,
|
||||
}));
|
||||
|
||||
const cursorSessions = (project.cursorSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'cursor' as const,
|
||||
}));
|
||||
|
||||
const codexSessions = (project.codexSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'codex' as const,
|
||||
}));
|
||||
|
||||
const geminiSessions = (project.geminiSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'gemini' as const,
|
||||
}));
|
||||
|
||||
const opencodeSessions = (project.opencodeSessions || []).map((session) => ({
|
||||
...session,
|
||||
__provider: 'opencode' as const,
|
||||
}));
|
||||
|
||||
return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions, ...opencodeSessions].sort(
|
||||
__provider: getSessionProvider(session),
|
||||
})).sort(
|
||||
(a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ function Sidebar({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
@@ -70,6 +71,7 @@ function Sidebar({
|
||||
isSearching,
|
||||
searchProgress,
|
||||
clearConversationResults,
|
||||
runningSessionsCount,
|
||||
deletingProjects,
|
||||
deleteConfirmation,
|
||||
sessionDeleteConfirmation,
|
||||
@@ -113,6 +115,7 @@ function Sidebar({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
isLoading,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -159,6 +162,8 @@ function Sidebar({
|
||||
mcpServerStatus,
|
||||
getProjectSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
forceExpanded: searchMode === 'running',
|
||||
isProjectStarred,
|
||||
onEditingNameChange: setEditingName,
|
||||
onToggleProject: toggleProject,
|
||||
@@ -229,6 +234,7 @@ function Sidebar({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
projects={projects}
|
||||
runningSessionsCount={runningSessionsCount}
|
||||
archivedProjects={archivedProjects}
|
||||
archivedSessions={archivedSessions}
|
||||
archivedSessionsCount={archivedSessionsCount}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import { Activity, Archive, Folder, MessageSquare, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { ScrollArea } from '../../../../shared/view/ui';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||
import type { ArchivedProjectListItem, ArchivedSessionListItem, SidebarSearchMode } from '../../types/types';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import { getAllSessions } from '../../utils/utils';
|
||||
|
||||
import SidebarFooter from './SidebarFooter';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||
import { getAllSessions } from '../../utils/utils';
|
||||
|
||||
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
||||
const parts: ReactNode[] = [];
|
||||
@@ -114,6 +116,7 @@ type SidebarContentProps = {
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
projects: Project[];
|
||||
runningSessionsCount: number;
|
||||
archivedProjects: ArchivedProjectListItem[];
|
||||
archivedSessions: ArchivedSessionListItem[];
|
||||
archivedSessionsCount: number;
|
||||
@@ -152,6 +155,7 @@ export default function SidebarContent({
|
||||
isMobile,
|
||||
isLoading,
|
||||
projects,
|
||||
runningSessionsCount,
|
||||
archivedProjects,
|
||||
archivedSessions,
|
||||
archivedSessionsCount,
|
||||
@@ -196,6 +200,7 @@ export default function SidebarContent({
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
projectsCount={projects.length}
|
||||
runningSessionsCount={runningSessionsCount}
|
||||
archivedSessionsCount={archivedSessionsCount}
|
||||
isArchivedSessionsLoading={isArchivedSessionsLoading}
|
||||
searchFilter={searchFilter}
|
||||
@@ -307,6 +312,39 @@ export default function SidebarContent({
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
) : searchMode === 'running' ? (
|
||||
projectListProps.filteredProjects.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border/70 bg-muted/50 md:mb-3">
|
||||
<Activity className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">
|
||||
{t('running.emptyTitle', 'No sessions running')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{runningSessionsCount > 0
|
||||
? t('running.noMatchingSessions', 'No running sessions match this search.')
|
||||
: t('running.emptyDescription', 'Active work will appear here while a provider is processing.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="mx-2 flex items-center justify-between rounded-lg border border-border/60 bg-card/50 px-3 py-2 shadow-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t('running.title', 'Running now')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{runningSessionsCount}
|
||||
</span>
|
||||
</div>
|
||||
<SidebarProjectList {...projectListProps} />
|
||||
</div>
|
||||
)
|
||||
) : searchMode === 'archived' ? (
|
||||
isArchivedSessionsLoading ? (
|
||||
<div className="px-4 py-12 text-center md:py-8">
|
||||
@@ -358,7 +396,7 @@ export default function SidebarContent({
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{project.displayName}
|
||||
</span>
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -448,7 +486,7 @@ export default function SidebarContent({
|
||||
{group.projectDisplayName}
|
||||
</span>
|
||||
{group.isProjectArchived && (
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-center text-muted-foreground">
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-muted px-1 py-px text-center text-[7px] font-medium uppercase leading-none tracking-[0.02em] text-muted-foreground">
|
||||
{t('archived.projectArchived', 'Project archived')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||
import { Activity, Archive, Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Button, Input, Tooltip } from '../../../../shared/view/ui';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { SidebarSearchMode } from '../../types/types';
|
||||
|
||||
import GitHubStarBadge from './GitHubStarBadge';
|
||||
|
||||
const MOD_KEY =
|
||||
@@ -14,6 +16,7 @@ type SidebarHeaderProps = {
|
||||
isMobile: boolean;
|
||||
isLoading: boolean;
|
||||
projectsCount: number;
|
||||
runningSessionsCount: number;
|
||||
archivedSessionsCount: number;
|
||||
isArchivedSessionsLoading: boolean;
|
||||
searchFilter: string;
|
||||
@@ -33,6 +36,7 @@ export default function SidebarHeader({
|
||||
isMobile,
|
||||
isLoading,
|
||||
projectsCount,
|
||||
runningSessionsCount,
|
||||
archivedSessionsCount,
|
||||
isArchivedSessionsLoading,
|
||||
searchFilter,
|
||||
@@ -46,12 +50,15 @@ export default function SidebarHeader({
|
||||
onCollapseSidebar,
|
||||
t,
|
||||
}: SidebarHeaderProps) {
|
||||
const showSearchTools = (projectsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
|
||||
const showSearchTools = (projectsCount > 0 || runningSessionsCount > 0 || archivedSessionsCount > 0 || isArchivedSessionsLoading) && !isLoading;
|
||||
const searchPlaceholder = searchMode === 'conversations'
|
||||
? t('search.conversationsPlaceholder')
|
||||
: searchMode === 'archived'
|
||||
? t('search.archivedPlaceholder', 'Search archived sessions...')
|
||||
: t('projects.searchPlaceholder');
|
||||
: searchMode === 'running'
|
||||
? t('search.runningPlaceholder', 'Search running sessions...')
|
||||
: t('projects.searchPlaceholder');
|
||||
const runningBadgeText = runningSessionsCount > 99 ? '99+' : String(runningSessionsCount);
|
||||
|
||||
const LogoBlock = () => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
@@ -153,6 +160,29 @@ export default function SidebarHeader({
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
<Tooltip content={t('search.runningTooltip', 'Running sessions')} position="top">
|
||||
<button
|
||||
onClick={() => onSearchModeChange('running')}
|
||||
aria-pressed={searchMode === 'running'}
|
||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||
title={t('search.runningTooltip', 'Running sessions')}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'running'
|
||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-3 w-3 items-center justify-center">
|
||||
<Activity className={cn("h-3 w-3", runningSessionsCount > 0 && "text-emerald-500")} />
|
||||
{runningSessionsCount > 0 && (
|
||||
<span className="absolute -right-2.5 -top-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-emerald-500 px-0.5 text-[8px] font-semibold leading-none text-white shadow-sm ring-1 ring-background">
|
||||
{runningBadgeText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
||||
<button
|
||||
onClick={() => onSearchModeChange('archived')}
|
||||
@@ -270,6 +300,30 @@ export default function SidebarHeader({
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
<Tooltip content={t('search.runningTooltip', 'Running sessions')} position="top">
|
||||
<button
|
||||
onClick={() => onSearchModeChange('running')}
|
||||
aria-pressed={searchMode === 'running'}
|
||||
aria-label={t('search.runningTooltip', 'Running sessions')}
|
||||
title={t('search.runningTooltip', 'Running sessions')}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'running'
|
||||
? "bg-background shadow-sm text-foreground ring-1 ring-emerald-500/15"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-3 w-3 items-center justify-center">
|
||||
<Activity className={cn("h-3 w-3", runningSessionsCount > 0 && "text-emerald-500")} />
|
||||
{runningSessionsCount > 0 && (
|
||||
<span className="absolute -right-2.5 -top-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-emerald-500 px-0.5 text-[8px] font-semibold leading-none text-white shadow-sm ring-1 ring-background">
|
||||
{runningBadgeText}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="sr-only">{t('search.modeRunning', 'Running')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('search.archiveOnlyTooltip', 'Archive only')} position="top">
|
||||
<button
|
||||
onClick={() => onSearchModeChange('archived')}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
||||
import { getTaskIndicatorStatus } from '../../utils/utils';
|
||||
|
||||
@@ -43,6 +44,7 @@ type SidebarProjectItemProps = {
|
||||
provider: LLMProvider,
|
||||
) => void;
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
activeSessions: SessionActivityMap;
|
||||
onNewSession: (project: Project) => void;
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
@@ -84,6 +86,7 @@ export default function SidebarProjectItem({
|
||||
onSessionSelect,
|
||||
onDeleteSession,
|
||||
onLoadMoreSessions,
|
||||
activeSessions,
|
||||
onNewSession,
|
||||
onEditingSessionNameChange,
|
||||
onStartEditingSession,
|
||||
@@ -395,6 +398,7 @@ export default function SidebarProjectItem({
|
||||
initialSessionsLoaded={initialSessionsLoaded}
|
||||
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
||||
isLoadingMoreSessions={isLoadingMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
||||
|
||||
import SidebarProjectItem from './SidebarProjectItem';
|
||||
@@ -27,6 +28,8 @@ export type SidebarProjectListProps = {
|
||||
getProjectSessions: (project: Project) => SessionWithProvider[];
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
loadingMoreProjects: Set<string>;
|
||||
activeSessions: SessionActivityMap;
|
||||
forceExpanded?: boolean;
|
||||
isProjectStarred: (projectName: string) => boolean;
|
||||
onEditingNameChange: (value: string) => void;
|
||||
onToggleProject: (projectName: string) => void;
|
||||
@@ -71,6 +74,8 @@ export default function SidebarProjectList({
|
||||
getProjectSessions,
|
||||
onLoadMoreSessions,
|
||||
loadingMoreProjects,
|
||||
activeSessions,
|
||||
forceExpanded = false,
|
||||
isProjectStarred,
|
||||
onEditingNameChange,
|
||||
onToggleProject,
|
||||
@@ -122,7 +127,7 @@ export default function SidebarProjectList({
|
||||
project={project}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
isExpanded={expandedProjects.has(project.projectId)}
|
||||
isExpanded={forceExpanded || expandedProjects.has(project.projectId)}
|
||||
isDeleting={deletingProjects.has(project.projectId)}
|
||||
isStarred={isProjectStarred(project.projectId)}
|
||||
editingProject={editingProject}
|
||||
@@ -146,6 +151,7 @@ export default function SidebarProjectList({
|
||||
onSessionSelect={onSessionSelect}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onLoadMoreSessions={onLoadMoreSessions}
|
||||
activeSessions={activeSessions}
|
||||
onNewSession={onNewSession}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onStartEditingSession={onStartEditingSession}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Plus } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import type { SessionActivityMap } from '../../../../hooks/useSessionProtection';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider } from '../../types/types';
|
||||
|
||||
@@ -15,6 +16,7 @@ type SidebarProjectSessionsProps = {
|
||||
initialSessionsLoaded: boolean;
|
||||
hasMoreSessions: boolean;
|
||||
isLoadingMoreSessions: boolean;
|
||||
activeSessions: SessionActivityMap;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -61,6 +63,7 @@ export default function SidebarProjectSessions({
|
||||
initialSessionsLoaded,
|
||||
hasMoreSessions,
|
||||
isLoadingMoreSessions,
|
||||
activeSessions,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -120,6 +123,7 @@ export default function SidebarProjectSessions({
|
||||
project={project}
|
||||
session={session}
|
||||
selectedSession={selectedSession}
|
||||
isProcessing={activeSessions.has(session.id)}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
||||
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
||||
@@ -13,6 +13,7 @@ type SidebarSessionItemProps = {
|
||||
project: Project;
|
||||
session: SessionWithProvider;
|
||||
selectedSession: ProjectSession | null;
|
||||
isProcessing: boolean;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -63,6 +64,7 @@ export default function SidebarSessionItem({
|
||||
project,
|
||||
session,
|
||||
selectedSession,
|
||||
isProcessing,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -80,6 +82,7 @@ export default function SidebarSessionItem({
|
||||
const isEditing = editingSession === session.id;
|
||||
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||
const editingContainerRef = useRef<HTMLDivElement>(null);
|
||||
const showRecentIndicator = !isProcessing && sessionView.isActive;
|
||||
|
||||
// The rename panel sits inside a group-hover opacity wrapper, so leaving the row
|
||||
// would visually hide it. While editing, dismiss only when the user clicks outside
|
||||
@@ -117,7 +120,7 @@ export default function SidebarSessionItem({
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
{sessionView.isActive && (
|
||||
{showRecentIndicator && (
|
||||
<div className="absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform">
|
||||
<Tooltip content={t('tooltips.activeSessionIndicator')} position="right">
|
||||
<div
|
||||
@@ -134,7 +137,9 @@ export default function SidebarSessionItem({
|
||||
className={cn(
|
||||
'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',
|
||||
isSelected ? 'bg-primary/5 border-primary/20' : '',
|
||||
!isSelected && sessionView.isActive
|
||||
!isSelected && isProcessing
|
||||
? 'border-border/60 bg-muted/20'
|
||||
: !isSelected && sessionView.isActive
|
||||
? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'
|
||||
: 'border-border/30',
|
||||
)}
|
||||
@@ -152,8 +157,16 @@ export default function SidebarSessionItem({
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{compactSessionAge && (
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span className="ml-auto flex-shrink-0">
|
||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : compactSessionAge && (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -166,7 +179,7 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sessionView.isCursorSession && (
|
||||
{!isProcessing && (
|
||||
<button
|
||||
className="ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20"
|
||||
onClick={(event) => {
|
||||
@@ -185,17 +198,42 @@ export default function SidebarSessionItem({
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
||||
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
||||
!isSelected && isProcessing
|
||||
? 'border-border/60 bg-muted/20 hover:bg-muted/25'
|
||||
: !isSelected && sessionView.isActive
|
||||
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
||||
: 'hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => onSessionSelect(session, project.projectId)}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-start gap-2">
|
||||
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
|
||||
isSelected ? 'bg-primary/10' : 'bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{compactSessionAge && (
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{isProcessing ? (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto flex-shrink-0 transition-opacity duration-200',
|
||||
isEditing ? 'opacity-0' : 'group-hover:opacity-0',
|
||||
)}
|
||||
>
|
||||
<Tooltip content={t('tooltips.processingSessionIndicator', 'Processing session')} position="top">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : compactSessionAge && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200',
|
||||
@@ -271,7 +309,7 @@ export default function SidebarSessionItem({
|
||||
>
|
||||
<Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
{!sessionView.isCursorSession && (
|
||||
{!isProcessing && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40"
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -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,34 +2,86 @@ 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';
|
||||
|
||||
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: Set<string>;
|
||||
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;
|
||||
providerSessionId?: string | null;
|
||||
provider: LLMProvider;
|
||||
session: ProjectSession;
|
||||
project: {
|
||||
projectId: string;
|
||||
path: string;
|
||||
fullPath: string;
|
||||
displayName: string;
|
||||
isStarred: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type FetchProjectsOptions = {
|
||||
showLoadingState?: boolean;
|
||||
};
|
||||
|
||||
type RegisterOptimisticSessionArgs = {
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
project: Project;
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
type ProjectSessionPage = Pick<Project, 'sessions' | 'sessionMeta'>;
|
||||
|
||||
const DEFAULT_PROVIDER: LLMProvider = 'claude';
|
||||
|
||||
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||
|
||||
const readSelectedProvider = (): LLMProvider => {
|
||||
try {
|
||||
const storedProvider = localStorage.getItem('selected-provider');
|
||||
return storedProvider ? storedProvider as LLMProvider : DEFAULT_PROVIDER;
|
||||
} catch {
|
||||
return DEFAULT_PROVIDER;
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionProvider = (session: ProjectSession): LLMProvider => {
|
||||
const provider = session.__provider ?? session.provider;
|
||||
return typeof provider === 'string' && provider.trim()
|
||||
? provider as LLMProvider
|
||||
: DEFAULT_PROVIDER;
|
||||
};
|
||||
|
||||
const normalizeSessionProvider = (session: ProjectSession): ProjectSession => ({
|
||||
...session,
|
||||
__provider: getSessionProvider(session),
|
||||
});
|
||||
|
||||
const projectsHaveChanges = (
|
||||
prevProjects: Project[],
|
||||
nextProjects: Project[],
|
||||
includeExternalSessions: boolean,
|
||||
): boolean => {
|
||||
if (prevProjects.length !== nextProjects.length) {
|
||||
return true;
|
||||
@@ -41,28 +93,14 @@ const projectsHaveChanges = (
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseChanged =
|
||||
return (
|
||||
nextProject.projectId !== prevProject.projectId ||
|
||||
nextProject.displayName !== prevProject.displayName ||
|
||||
nextProject.fullPath !== prevProject.fullPath ||
|
||||
Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) ||
|
||||
serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||
|
||||
serialize(nextProject.sessions) !== serialize(prevProject.sessions) ||
|
||||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster);
|
||||
|
||||
if (baseChanged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!includeExternalSessions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||
|
||||
serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||
|
||||
serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions) ||
|
||||
serialize(nextProject.opencodeSessions) !== serialize(prevProject.opencodeSessions)
|
||||
serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster)
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -94,13 +132,7 @@ const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project
|
||||
};
|
||||
|
||||
const getProjectSessions = (project: Project): ProjectSession[] => {
|
||||
return [
|
||||
...(project.sessions ?? []),
|
||||
...(project.codexSessions ?? []),
|
||||
...(project.cursorSessions ?? []),
|
||||
...(project.geminiSessions ?? []),
|
||||
...(project.opencodeSessions ?? []),
|
||||
];
|
||||
return project.sessions ?? [];
|
||||
};
|
||||
|
||||
const countLoadedProjectSessions = (project: Project): number => getProjectSessions(project).length;
|
||||
@@ -144,10 +176,6 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
const mergedProject: Project = {
|
||||
...incomingProject,
|
||||
sessions: mergeSessionProviderLists(incomingProject.sessions ?? [], previousProject.sessions ?? []),
|
||||
cursorSessions: mergeSessionProviderLists(incomingProject.cursorSessions ?? [], previousProject.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(incomingProject.codexSessions ?? [], previousProject.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(incomingProject.geminiSessions ?? [], previousProject.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(incomingProject.opencodeSessions ?? [], previousProject.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(incomingProject.sessionMeta?.total ?? previousLoadedCount);
|
||||
@@ -163,15 +191,11 @@ const mergeExpandedSessionPages = (previousProjects: Project[], incomingProjects
|
||||
|
||||
const mergeProjectSessionPage = (
|
||||
existingProject: Project,
|
||||
sessionsPage: Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>,
|
||||
sessionsPage: ProjectSessionPage,
|
||||
): Project => {
|
||||
const mergedProject: Project = {
|
||||
...existingProject,
|
||||
sessions: mergeSessionProviderLists(existingProject.sessions ?? [], sessionsPage.sessions ?? []),
|
||||
cursorSessions: mergeSessionProviderLists(existingProject.cursorSessions ?? [], sessionsPage.cursorSessions ?? []),
|
||||
codexSessions: mergeSessionProviderLists(existingProject.codexSessions ?? [], sessionsPage.codexSessions ?? []),
|
||||
geminiSessions: mergeSessionProviderLists(existingProject.geminiSessions ?? [], sessionsPage.geminiSessions ?? []),
|
||||
opencodeSessions: mergeSessionProviderLists(existingProject.opencodeSessions ?? [], sessionsPage.opencodeSessions ?? []),
|
||||
};
|
||||
|
||||
const totalSessions = Number(sessionsPage.sessionMeta?.total ?? existingProject.sessionMeta?.total ?? 0);
|
||||
@@ -185,40 +209,119 @@ const mergeProjectSessionPage = (
|
||||
return mergedProject;
|
||||
};
|
||||
|
||||
const isUpdateAdditive = (
|
||||
currentProjects: Project[],
|
||||
updatedProjects: Project[],
|
||||
selectedProject: Project | null,
|
||||
selectedSession: ProjectSession | null,
|
||||
): boolean => {
|
||||
if (!selectedProject || !selectedSession) {
|
||||
return true;
|
||||
const getSessionAliasIds = (event: SessionUpsertedEvent): Set<string> => {
|
||||
const ids = new Set<string>();
|
||||
const add = (value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
add(event.sessionId);
|
||||
add(event.providerSessionId);
|
||||
add(event.session?.id);
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upserts one session into a project's normalized session list.
|
||||
*
|
||||
* 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 sessions = project.sessions ?? [];
|
||||
const aliasIds = getSessionAliasIds(event);
|
||||
const normalizedSession: ProjectSession = {
|
||||
...event.session,
|
||||
id: event.sessionId,
|
||||
__provider: event.provider,
|
||||
};
|
||||
const existingIndex = sessions.findIndex((session) => aliasIds.has(String(session.id)));
|
||||
|
||||
let nextSessions: ProjectSession[];
|
||||
let inserted = false;
|
||||
if (existingIndex >= 0) {
|
||||
let changed = false;
|
||||
nextSessions = [];
|
||||
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (index === existingIndex) {
|
||||
const updated = { ...session, ...normalizedSession };
|
||||
if (serialize(session) !== serialize(updated)) {
|
||||
changed = true;
|
||||
}
|
||||
nextSessions.push(updated);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (aliasIds.has(String(session.id))) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextSessions.push(session);
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return project;
|
||||
}
|
||||
} else {
|
||||
nextSessions = [normalizedSession, ...sessions];
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
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, sessions: nextSessions };
|
||||
if (inserted) {
|
||||
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,
|
||||
);
|
||||
return next;
|
||||
};
|
||||
|
||||
if (!currentSelectedSession || !updatedSelectedSession) {
|
||||
return false;
|
||||
const projectFromRegistration = (project: Project): Project => ({
|
||||
projectId: project.projectId,
|
||||
path: project.path || project.fullPath,
|
||||
fullPath: project.fullPath || project.path || '',
|
||||
displayName: project.displayName,
|
||||
isStarred: project.isStarred,
|
||||
sessions: project.sessions ?? [],
|
||||
sessionMeta: project.sessionMeta ?? { hasMore: false, total: countLoadedProjectSessions(project) },
|
||||
taskmaster: project.taskmaster,
|
||||
});
|
||||
|
||||
const removeSessionFromProject = (project: Project, sessionIdToDelete: string): Project => {
|
||||
const sessions = project.sessions ?? [];
|
||||
const nextSessions = sessions.filter((session) => session.id !== sessionIdToDelete);
|
||||
if (nextSessions.length === sessions.length) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return (
|
||||
currentSelectedSession.id === updatedSelectedSession.id &&
|
||||
currentSelectedSession.title === updatedSelectedSession.title &&
|
||||
currentSelectedSession.created_at === updatedSelectedSession.created_at &&
|
||||
currentSelectedSession.updated_at === updatedSelectedSession.updated_at
|
||||
);
|
||||
const updatedProject: Project = {
|
||||
...project,
|
||||
sessions: nextSessions,
|
||||
};
|
||||
|
||||
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
|
||||
updatedProject.sessionMeta = {
|
||||
...project.sessionMeta,
|
||||
total: totalSessions,
|
||||
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
|
||||
};
|
||||
|
||||
return updatedProject;
|
||||
};
|
||||
|
||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser']);
|
||||
@@ -242,7 +345,7 @@ const readPersistedTab = (): AppTab => {
|
||||
export function useProjectsState({
|
||||
sessionId,
|
||||
navigate,
|
||||
latestMessage,
|
||||
subscribe,
|
||||
isMobile,
|
||||
activeSessions,
|
||||
}: UseProjectsStateArgs) {
|
||||
@@ -289,7 +392,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 {
|
||||
@@ -307,7 +421,7 @@ export function useProjectsState({
|
||||
return mergedProjects;
|
||||
}
|
||||
|
||||
return projectsHaveChanges(prevProjects, mergedProjects, true)
|
||||
return projectsHaveChanges(prevProjects, mergedProjects)
|
||||
? mergedProjects
|
||||
: prevProjects;
|
||||
});
|
||||
@@ -325,6 +439,75 @@ export function useProjectsState({
|
||||
await fetchProjects({ showLoadingState: false });
|
||||
}, [fetchProjects]);
|
||||
|
||||
const registerOptimisticSession = useCallback(({
|
||||
sessionId: newSessionId,
|
||||
provider,
|
||||
project,
|
||||
summary,
|
||||
}: RegisterOptimisticSessionArgs) => {
|
||||
if (!newSessionId || !project?.projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const optimisticSession: ProjectSession = {
|
||||
id: newSessionId,
|
||||
summary: summary ?? '',
|
||||
messageCount: 0,
|
||||
createdAt: now,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
lastActivity: now,
|
||||
__provider: provider,
|
||||
__projectId: project.projectId,
|
||||
};
|
||||
const upsert: SessionUpsertedEvent = {
|
||||
kind: 'session_upserted',
|
||||
sessionId: newSessionId,
|
||||
provider,
|
||||
session: optimisticSession,
|
||||
project: {
|
||||
projectId: project.projectId,
|
||||
path: project.path || project.fullPath,
|
||||
fullPath: project.fullPath || project.path || '',
|
||||
displayName: project.displayName,
|
||||
isStarred: Boolean(project.isStarred),
|
||||
},
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
setProjects((previousProjects) => {
|
||||
const existingProject = previousProjects.find((candidate) => candidate.projectId === project.projectId);
|
||||
if (!existingProject) {
|
||||
return [upsertSessionIntoProject(projectFromRegistration(project), upsert), ...previousProjects];
|
||||
}
|
||||
|
||||
const updatedProject = upsertSessionIntoProject(existingProject, upsert);
|
||||
if (updatedProject === existingProject) {
|
||||
return previousProjects;
|
||||
}
|
||||
|
||||
return previousProjects.map((candidate) =>
|
||||
candidate.projectId === existingProject.projectId ? updatedProject : candidate,
|
||||
);
|
||||
});
|
||||
|
||||
setSelectedProject((previousProject) => {
|
||||
if (!previousProject || previousProject.projectId !== project.projectId) {
|
||||
return previousProject;
|
||||
}
|
||||
|
||||
const updatedProject = upsertSessionIntoProject(previousProject, upsert);
|
||||
return updatedProject === previousProject ? previousProject : updatedProject;
|
||||
});
|
||||
|
||||
setSelectedSession((previousSession) => (
|
||||
previousSession?.id === newSessionId
|
||||
? { ...previousSession, ...optimisticSession }
|
||||
: optimisticSession
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Hydrates TaskMaster details for the given `projectId`. The project
|
||||
// identifier comes directly from the DB-driven /api/projects response.
|
||||
const hydrateProjectTaskMaster = useCallback(async (projectId: string) => {
|
||||
@@ -391,98 +574,135 @@ 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: [],
|
||||
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]);
|
||||
const aliasedSelectedSessionId =
|
||||
typeof upsert.providerSessionId === 'string' && upsert.providerSessionId !== upsert.sessionId
|
||||
? upsert.providerSessionId
|
||||
: null;
|
||||
if (!aliasedSelectedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSelectedSession: ProjectSession = {
|
||||
...upsert.session,
|
||||
id: upsert.sessionId,
|
||||
__provider: upsert.provider,
|
||||
__projectId: upsert.project?.projectId ?? currentSelectedSession?.__projectId,
|
||||
};
|
||||
|
||||
setSelectedSession((previousSession) => {
|
||||
if (previousSession?.id !== aliasedSelectedSessionId) {
|
||||
return previousSession;
|
||||
}
|
||||
|
||||
return {
|
||||
...previousSession,
|
||||
...normalizedSelectedSession,
|
||||
};
|
||||
});
|
||||
|
||||
if (sessionId === aliasedSelectedSessionId) {
|
||||
navigate(`/session/${upsert.sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
return subscribe(handleEvent);
|
||||
}, [navigate, sessionId, subscribe]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -500,86 +720,29 @@ export function useProjectsState({
|
||||
|
||||
// Project membership is resolved through `projectId` after the migration.
|
||||
for (const project of projects) {
|
||||
const claudeSession = project.sessions?.find((session) => session.id === sessionId);
|
||||
if (claudeSession) {
|
||||
const match = project.sessions?.find((session) => session.id === sessionId);
|
||||
if (match) {
|
||||
const normalizedSession = normalizeSessionProvider(match);
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== normalizedSession.__provider;
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...claudeSession, __provider: 'claude' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);
|
||||
if (cursorSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...cursorSession, __provider: 'cursor' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const codexSession = project.codexSessions?.find((session) => session.id === sessionId);
|
||||
if (codexSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...codexSession, __provider: 'codex' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);
|
||||
if (geminiSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...geminiSession, __provider: 'gemini' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const opencodeSession = project.opencodeSessions?.find((session) => session.id === sessionId);
|
||||
if (opencodeSession) {
|
||||
const shouldUpdateProject = selectedProject?.projectId !== project.projectId;
|
||||
const shouldUpdateSession =
|
||||
selectedSession?.id !== sessionId || selectedSession.__provider !== 'opencode';
|
||||
|
||||
if (shouldUpdateProject) {
|
||||
setSelectedProject(project);
|
||||
}
|
||||
if (shouldUpdateSession) {
|
||||
setSelectedSession({ ...opencodeSession, __provider: 'opencode' });
|
||||
setSelectedSession(normalizedSession);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -588,27 +751,9 @@ export function useProjectsState({
|
||||
return;
|
||||
}
|
||||
|
||||
let providerFromStorage: string | null = null;
|
||||
try {
|
||||
providerFromStorage = localStorage.getItem('selected-provider');
|
||||
} catch {
|
||||
providerFromStorage = null;
|
||||
}
|
||||
|
||||
const normalizedProvider: LLMProvider =
|
||||
providerFromStorage === 'cursor'
|
||||
? 'cursor'
|
||||
: providerFromStorage === 'codex'
|
||||
? 'codex'
|
||||
: providerFromStorage === 'gemini'
|
||||
? 'gemini'
|
||||
: providerFromStorage === 'opencode'
|
||||
? 'opencode'
|
||||
: 'claude';
|
||||
|
||||
setSelectedSession({
|
||||
id: sessionId,
|
||||
__provider: normalizedProvider,
|
||||
__provider: readSelectedProvider(),
|
||||
__projectId: selectedProject.projectId,
|
||||
summary: '',
|
||||
});
|
||||
@@ -635,11 +780,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
|
||||
@@ -681,43 +821,7 @@ export function useProjectsState({
|
||||
}
|
||||
|
||||
setProjects((prevProjects) =>
|
||||
prevProjects.map((project) => {
|
||||
const sessions = project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const cursorSessions = project.cursorSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const codexSessions = project.codexSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const geminiSessions = project.geminiSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
const opencodeSessions = project.opencodeSessions?.filter((session) => session.id !== sessionIdToDelete) ?? [];
|
||||
|
||||
const removedFromProject = (
|
||||
sessions.length !== (project.sessions?.length ?? 0)
|
||||
|| cursorSessions.length !== (project.cursorSessions?.length ?? 0)
|
||||
|| codexSessions.length !== (project.codexSessions?.length ?? 0)
|
||||
|| geminiSessions.length !== (project.geminiSessions?.length ?? 0)
|
||||
|| opencodeSessions.length !== (project.opencodeSessions?.length ?? 0)
|
||||
);
|
||||
|
||||
if (!removedFromProject) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const updatedProject: Project = {
|
||||
...project,
|
||||
sessions,
|
||||
cursorSessions,
|
||||
codexSessions,
|
||||
geminiSessions,
|
||||
opencodeSessions,
|
||||
};
|
||||
|
||||
const totalSessions = Math.max(0, Number(project.sessionMeta?.total ?? 0) - 1);
|
||||
updatedProject.sessionMeta = {
|
||||
...project.sessionMeta,
|
||||
total: totalSessions,
|
||||
hasMore: countLoadedProjectSessions(updatedProject) < totalSessions,
|
||||
};
|
||||
|
||||
return updatedProject;
|
||||
}),
|
||||
prevProjects.map((project) => removeSessionFromProject(project, sessionIdToDelete)),
|
||||
);
|
||||
},
|
||||
[navigate, selectedSession?.id],
|
||||
@@ -731,7 +835,7 @@ export function useProjectsState({
|
||||
const mergedProjects = mergeExpandedSessionPages(projects, projectsWithTaskMaster);
|
||||
|
||||
setProjects((prevProjects) =>
|
||||
projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects,
|
||||
projectsHaveChanges(prevProjects, mergedProjects) ? mergedProjects : prevProjects,
|
||||
);
|
||||
|
||||
if (!selectedProject) {
|
||||
@@ -800,7 +904,7 @@ export function useProjectsState({
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const sessionsPage = (await response.json()) as Pick<Project, 'sessions' | 'cursorSessions' | 'codexSessions' | 'geminiSessions' | 'opencodeSessions' | 'sessionMeta'>;
|
||||
const sessionsPage = (await response.json()) as ProjectSessionPage;
|
||||
|
||||
let mergedProjectForSelection: Project | null = null;
|
||||
setProjects((previousProjects) =>
|
||||
@@ -840,6 +944,7 @@ export function useProjectsState({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
activeSessions,
|
||||
onProjectSelect: handleProjectSelect,
|
||||
onSessionSelect: handleSessionSelect,
|
||||
onNewSession: handleNewSession,
|
||||
@@ -866,6 +971,7 @@ export function useProjectsState({
|
||||
isLoadingProjects,
|
||||
isMobile,
|
||||
loadingProgress,
|
||||
activeSessions,
|
||||
projects,
|
||||
settingsInitialTab,
|
||||
selectedProject,
|
||||
@@ -894,6 +1000,7 @@ export function useProjectsState({
|
||||
openSettings,
|
||||
fetchProjects,
|
||||
refreshProjectsSilently,
|
||||
registerOptimisticSession,
|
||||
sidebarSharedProps,
|
||||
handleProjectSelect,
|
||||
handleSessionSelect,
|
||||
|
||||
@@ -1,55 +1,172 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface SessionActivity {
|
||||
/** Provider-supplied status line; null renders the default activity label. */
|
||||
statusText: string | null;
|
||||
canInterrupt: boolean;
|
||||
/**
|
||||
* When this request was first marked as processing (client clock). Drives
|
||||
* the elapsed-time display and the stale `session-status` reply guard.
|
||||
*/
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export type SessionActivityMap = ReadonlyMap<string, SessionActivity>;
|
||||
|
||||
export type SessionActivitySnapshot = {
|
||||
sessionId: string;
|
||||
statusText?: string | null;
|
||||
canInterrupt?: boolean;
|
||||
startedAt?: number;
|
||||
};
|
||||
|
||||
export type MarkSessionProcessing = (
|
||||
sessionId?: string | null,
|
||||
activity?: { statusText?: string | null; canInterrupt?: boolean },
|
||||
) => void;
|
||||
|
||||
export type MarkSessionIdle = (
|
||||
sessionId?: string | null,
|
||||
opts?: { ifStartedBefore?: number },
|
||||
) => void;
|
||||
|
||||
export type SyncProcessingSessions = (
|
||||
sessions: readonly SessionActivitySnapshot[],
|
||||
) => void;
|
||||
|
||||
const LOCAL_ACTIVITY_GRACE_MS = 10_000;
|
||||
|
||||
const sessionActivityMapsMatch = (
|
||||
left: ReadonlyMap<string, SessionActivity>,
|
||||
right: ReadonlyMap<string, SessionActivity>,
|
||||
): boolean => {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [sessionId, leftActivity] of left) {
|
||||
const rightActivity = right.get(sessionId);
|
||||
if (
|
||||
!rightActivity
|
||||
|| leftActivity.statusText !== rightActivity.statusText
|
||||
|| leftActivity.canInterrupt !== rightActivity.canInterrupt
|
||||
|| leftActivity.startedAt !== rightActivity.startedAt
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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`, 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 [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
||||
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
|
||||
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const markSessionAsActive = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSessions((prev) => new Set([...prev, sessionId]));
|
||||
}, []);
|
||||
|
||||
const markSessionAsInactive = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSessions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sessionId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markSessionAsProcessing = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingSessions((prev) => new Set([...prev, sessionId]));
|
||||
}, []);
|
||||
|
||||
const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {
|
||||
const markSessionProcessing = useCallback<MarkSessionProcessing>((sessionId, activity) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingSessions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sessionId);
|
||||
return next;
|
||||
const existing = prev.get(sessionId);
|
||||
const next: SessionActivity = {
|
||||
statusText:
|
||||
activity?.statusText !== undefined ? activity.statusText : existing?.statusText ?? null,
|
||||
canInterrupt: activity?.canInterrupt ?? existing?.canInterrupt ?? true,
|
||||
startedAt: existing?.startedAt ?? Date.now(),
|
||||
};
|
||||
|
||||
if (
|
||||
existing
|
||||
&& existing.statusText === next.statusText
|
||||
&& existing.canInterrupt === next.canInterrupt
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const updated = new Map(prev);
|
||||
updated.set(sessionId, next);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const markSessionIdle = useCallback<MarkSessionIdle>((sessionId, opts) => {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingSessions((prev) => {
|
||||
const existing = prev.get(sessionId);
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
const updated = new Map(prev);
|
||||
updated.delete(sessionId);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const syncProcessingSessions = useCallback<SyncProcessingSessions>((sessions) => {
|
||||
const now = Date.now();
|
||||
|
||||
setProcessingSessions((prev) => {
|
||||
const incoming = new Map<string, SessionActivitySnapshot>();
|
||||
for (const session of sessions) {
|
||||
if (!session.sessionId) {
|
||||
continue;
|
||||
}
|
||||
incoming.set(session.sessionId, session);
|
||||
}
|
||||
|
||||
const updated = new Map<string, SessionActivity>();
|
||||
|
||||
for (const [sessionId, snapshot] of incoming) {
|
||||
const existing = prev.get(sessionId);
|
||||
const snapshotStartedAt =
|
||||
typeof snapshot.startedAt === 'number' && Number.isFinite(snapshot.startedAt) && snapshot.startedAt > 0
|
||||
? snapshot.startedAt
|
||||
: undefined;
|
||||
|
||||
updated.set(sessionId, {
|
||||
statusText:
|
||||
snapshot.statusText !== undefined ? snapshot.statusText : existing?.statusText ?? null,
|
||||
canInterrupt: snapshot.canInterrupt ?? existing?.canInterrupt ?? true,
|
||||
startedAt: snapshotStartedAt ?? existing?.startedAt ?? now,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [sessionId, activity] of prev) {
|
||||
if (!incoming.has(sessionId) && now - activity.startedAt < LOCAL_ACTIVITY_GRACE_MS) {
|
||||
updated.set(sessionId, activity);
|
||||
}
|
||||
}
|
||||
|
||||
return sessionActivityMapsMatch(prev, updated) ? prev : updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeSessions,
|
||||
processingSessions,
|
||||
markSessionAsActive,
|
||||
markSessionAsInactive,
|
||||
markSessionAsProcessing,
|
||||
markSessionAsNotProcessing,
|
||||
markSessionProcessing,
|
||||
markSessionIdle,
|
||||
syncProcessingSessions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"label": "{{time}} elapsed",
|
||||
"startingNow": "Starting now"
|
||||
},
|
||||
"stop": "Stop",
|
||||
"controls": {
|
||||
"stopGeneration": "Stop Generation",
|
||||
"pressEscToStop": "Press Esc anytime to stop"
|
||||
|
||||
@@ -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';
|
||||
@@ -122,12 +128,146 @@ function createEmptySlot(): SessionSlot {
|
||||
* assistant echo (same trimmed text), so finalized stream rows do not stack
|
||||
* on top of the persisted copy before realtime is cleared.
|
||||
*/
|
||||
const LOCAL_USER_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
||||
const LOCAL_USER_DEDUPE_CLOCK_SKEW_MS = 10_000;
|
||||
|
||||
function userTextFingerprint(m: NormalizedMessage): string | null {
|
||||
if (m.kind !== 'text' || m.role !== 'user') return null;
|
||||
const t = (m.content || '').trim();
|
||||
return t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
function readMessageTime(m: NormalizedMessage): number | null {
|
||||
const time = Date.parse(m.timestamp);
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function hasServerEchoForLocalUser(
|
||||
localMessage: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
): boolean {
|
||||
const localText = userTextFingerprint(localMessage);
|
||||
const localTime = readMessageTime(localMessage);
|
||||
if (!localText || localTime === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return serverMessages.some((serverMessage) => {
|
||||
if (userTextFingerprint(serverMessage) !== localText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverTime = readMessageTime(serverMessage);
|
||||
return (
|
||||
serverTime !== null
|
||||
&& serverTime >= localTime - LOCAL_USER_DEDUPE_CLOCK_SKEW_MS
|
||||
&& serverTime - localTime <= LOCAL_USER_DEDUPE_WINDOW_MS
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function compareMessagesChronologically(a: NormalizedMessage, b: NormalizedMessage): number {
|
||||
const timeA = readMessageTime(a) ?? 0;
|
||||
const timeB = readMessageTime(b) ?? 0;
|
||||
if (timeA !== timeB) {
|
||||
return timeA - timeB;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many user turns precede `message` in a chronologically merged view
|
||||
* of server + realtime rows. Used to match a realtime row to the correct turn
|
||||
* on disk when several turns share identical assistant text.
|
||||
*/
|
||||
function getUserTurnOrdinalBefore(
|
||||
message: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): number {
|
||||
const messageTime = readMessageTime(message);
|
||||
let userCount = 0;
|
||||
|
||||
for (const candidate of [...serverMessages, ...realtimeMessages].sort(compareMessagesChronologically)) {
|
||||
if (candidate.id === message.id) {
|
||||
break;
|
||||
}
|
||||
|
||||
const candidateTime = readMessageTime(candidate);
|
||||
if (
|
||||
messageTime !== null
|
||||
&& candidateTime !== null
|
||||
&& candidateTime > messageTime
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (candidate.kind === 'text' && candidate.role === 'user') {
|
||||
userCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, userCount - 1);
|
||||
}
|
||||
|
||||
function findServerTurnRangeByOrdinal(
|
||||
serverMessages: NormalizedMessage[],
|
||||
turnOrdinal: number,
|
||||
): { start: number; end: number } | null {
|
||||
let userCount = -1;
|
||||
let start = -1;
|
||||
|
||||
for (let index = 0; index < serverMessages.length; index++) {
|
||||
const message = serverMessages[index];
|
||||
if (message.kind === 'text' && message.role === 'user') {
|
||||
userCount++;
|
||||
if (userCount === turnOrdinal) {
|
||||
start = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let end = serverMessages.length;
|
||||
for (let index = start + 1; index < serverMessages.length; index++) {
|
||||
if (serverMessages[index].kind === 'text' && serverMessages[index].role === 'user') {
|
||||
end = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function isAssistantTextEchoedInSameTurnOnServer(
|
||||
message: NormalizedMessage,
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): boolean {
|
||||
const assistantText = (message.content || '').trim();
|
||||
if (!assistantText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const turnOrdinal = getUserTurnOrdinalBefore(message, serverMessages, realtimeMessages);
|
||||
const turnRange = findServerTurnRangeByOrdinal(serverMessages, turnOrdinal);
|
||||
if (!turnRange) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return serverMessages
|
||||
.slice(turnRange.start + 1, turnRange.end)
|
||||
.some((serverMessage) =>
|
||||
serverMessage.kind === 'text'
|
||||
&& serverMessage.role === 'assistant'
|
||||
&& (serverMessage.content || '').trim() === assistantText,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After `finalizeStreaming`, the client holds a synthetic assistant `text` row
|
||||
* while the sessions API soon returns the same reply with a different id.
|
||||
@@ -165,79 +305,92 @@ function dedupeAdjacentAssistantEchoes(merged: NormalizedMessage[]): NormalizedM
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a server refresh, drop only the realtime rows the persisted transcript
|
||||
* already owns. Anything not yet on disk (common right after `complete`, while
|
||||
* JSONL indexing lags) stays in `realtimeMessages` so the chat pane never
|
||||
* flashes the empty "Continue your conversation" state.
|
||||
*/
|
||||
function pruneRealtimeSupersededByServer(
|
||||
serverMessages: NormalizedMessage[],
|
||||
realtimeMessages: NormalizedMessage[],
|
||||
): NormalizedMessage[] {
|
||||
if (realtimeMessages.length === 0) {
|
||||
return realtimeMessages;
|
||||
}
|
||||
|
||||
const serverIds = new Set(serverMessages.map((message) => message.id));
|
||||
|
||||
return realtimeMessages.filter((message) => {
|
||||
if (serverIds.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.id.startsWith('local_') && hasServerEchoForLocalUser(message, serverMessages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.kind === 'stream_delta' || message.id === `__streaming_${message.sessionId}`) {
|
||||
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.kind === 'text' && message.role === 'assistant') {
|
||||
if (isAssistantTextEchoedInSameTurnOnServer(message, serverMessages, realtimeMessages)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.kind === 'text' && message.role === 'user') {
|
||||
return !hasServerEchoForLocalUser(message, serverMessages);
|
||||
}
|
||||
|
||||
if (message.kind === 'tool_use' && message.toolId) {
|
||||
if (serverMessages.some((serverMessage) => serverMessage.kind === 'tool_use' && serverMessage.toolId === message.toolId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {
|
||||
if (realtime.length === 0) return server;
|
||||
if (server.length === 0) return dedupeAdjacentAssistantEchoes(realtime);
|
||||
const serverIds = new Set(server.map(m => m.id));
|
||||
const serverUserTexts = new Set(
|
||||
server.map(userTextFingerprint).filter((t): t is string => t !== null),
|
||||
);
|
||||
const extra = realtime.filter((m) => {
|
||||
if (serverIds.has(m.id)) return false;
|
||||
if (realtime.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(server);
|
||||
}
|
||||
if (server.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(realtime);
|
||||
}
|
||||
|
||||
const serverIds = new Set(server.map((message) => message.id));
|
||||
const extra = realtime.filter((message) => {
|
||||
if (serverIds.has(message.id)) {
|
||||
return false;
|
||||
}
|
||||
// Optimistic user rows use `local_*` ids; once the same text exists on the
|
||||
// server-backed copy, drop the realtime echo to avoid duplicate bubbles.
|
||||
if (m.id.startsWith('local_')) {
|
||||
const fp = userTextFingerprint(m);
|
||||
if (fp && serverUserTexts.has(fp)) return false;
|
||||
// server-backed copy from the same send window, drop the realtime echo to
|
||||
// avoid duplicate bubbles without hiding repeated prompts from history.
|
||||
if (message.id.startsWith('local_')) {
|
||||
if (hasServerEchoForLocalUser(message, server)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (extra.length === 0) return server;
|
||||
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;
|
||||
if (extra.length === 0) {
|
||||
return dedupeAdjacentAssistantEchoes(server);
|
||||
}
|
||||
|
||||
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;
|
||||
// Interleave by timestamp so live rows stay with their turn instead of
|
||||
// piling up at the bottom after every refresh.
|
||||
return dedupeAdjacentAssistantEchoes(
|
||||
[...server, ...extra].sort(compareMessagesChronologically),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,79 +417,50 @@ 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,
|
||||
opts: {
|
||||
provider?: LLMProvider;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
limit?: number | null;
|
||||
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 +470,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 +492,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.
|
||||
@@ -383,14 +508,10 @@ export function useSessionStore() {
|
||||
const fetchMore = useCallback(async (
|
||||
sessionId: string,
|
||||
opts: {
|
||||
provider?: LLMProvider;
|
||||
projectId?: string;
|
||||
projectPath?: string;
|
||||
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 +520,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 +534,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,76 +578,70 @@ 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.
|
||||
*/
|
||||
const refreshFromServer = useCallback(async (
|
||||
sessionId: string,
|
||||
_opts: {
|
||||
provider?: LLMProvider;
|
||||
projectId?: string;
|
||||
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;
|
||||
slot.hasMore = Boolean(data.hasMore);
|
||||
slot.fetchedAt = Date.now();
|
||||
// drop realtime messages that the server has caught up with to prevent unbounded growth.
|
||||
slot.realtimeMessages = [];
|
||||
// Only drop realtime rows the server transcript now owns. A blind clear
|
||||
// here caused the chat pane to flash "Continue your conversation" after
|
||||
// `complete` while JSONL / provider_session_id indexing was still behind.
|
||||
slot.realtimeMessages = pruneRealtimeSupersededByServer(
|
||||
slot.serverMessages,
|
||||
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 +655,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 +677,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 +723,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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ProjectSession {
|
||||
updated_at?: string;
|
||||
lastActivity?: string;
|
||||
messageCount?: number;
|
||||
provider?: LLMProvider;
|
||||
__provider?: LLMProvider;
|
||||
// Tags the session with the owning project's DB `projectId` so UI handlers
|
||||
// (session switching, sidebar focus, etc.) can match against selectedProject.
|
||||
@@ -60,42 +61,16 @@ export interface Project {
|
||||
path?: string;
|
||||
isStarred?: boolean;
|
||||
sessions?: ProjectSession[];
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
geminiSessions?: ProjectSession[];
|
||||
opencodeSessions?: ProjectSession[];
|
||||
sessionMeta?: ProjectSessionMeta;
|
||||
taskmaster?: ProjectTaskmasterInfo;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -98,6 +98,8 @@ export const api = {
|
||||
},
|
||||
getArchivedSessions: () =>
|
||||
authenticatedFetch('/api/providers/sessions/archived'),
|
||||
runningSessions: () =>
|
||||
authenticatedFetch('/api/providers/sessions/running'),
|
||||
restoreSession: (sessionId) =>
|
||||
authenticatedFetch(`/api/providers/sessions/${sessionId}/restore`, {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user