mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-26 13:35:49 +08:00
Merge upstream/main into feat/voice (resolve voice vs browser-use + websocket-unify)
This commit is contained in:
@@ -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,
|
||||
],
|
||||
@@ -955,29 +932,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 }) => {
|
||||
@@ -1002,7 +969,7 @@ export function useChatComposerState({
|
||||
|
||||
validIds.forEach((requestId) => {
|
||||
sendMessage({
|
||||
type: 'claude-permission-response',
|
||||
type: 'chat.permission-response',
|
||||
requestId,
|
||||
allow: Boolean(decision?.allow),
|
||||
updatedInput: decision?.updatedInput,
|
||||
@@ -1011,15 +978,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,263 @@ 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);
|
||||
if (sid === activeViewSessionId) {
|
||||
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);
|
||||
case 'permission_request': {
|
||||
if (!msg.requestId) break;
|
||||
if (sid === activeViewSessionId) {
|
||||
setPendingPermissionRequests((prev) => {
|
||||
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
|
||||
return [...prev, {
|
||||
requestId: msg.requestId as string,
|
||||
toolName: (msg.toolName as string) || 'UnknownTool',
|
||||
input: msg.input,
|
||||
context: msg.context,
|
||||
sessionId: sid || null,
|
||||
receivedAt: new Date(),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
if (isVisibleSession) {
|
||||
onNavigateToSession?.(actualSessionId, { replace: true });
|
||||
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
||||
if (sid) {
|
||||
onSessionProcessing?.(sid);
|
||||
}
|
||||
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));
|
||||
case 'permission_cancelled': {
|
||||
if (msg.requestId && sid === activeViewSessionId) {
|
||||
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
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,
|
||||
});
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(msg.canInterrupt !== false);
|
||||
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;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
|
||||
// → already routed to store above, no UI side effects needed
|
||||
default:
|
||||
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,20 +452,33 @@ export function useChatSessionState({
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`;
|
||||
const selectedSessionId = selectedSession.id;
|
||||
const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
|
||||
|
||||
const subscribeToSelectedSession = () => {
|
||||
if (!ws) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
|
||||
sendMessage({
|
||||
type: 'chat.subscribe',
|
||||
sessions: [{
|
||||
sessionId: selectedSessionId,
|
||||
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
// Skip if already loaded and fresh
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) {
|
||||
subscribeToSelectedSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId;
|
||||
if (sessionChanged) {
|
||||
resetStreamingState();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
}
|
||||
|
||||
// Reset pagination/scroll state
|
||||
@@ -482,27 +497,22 @@ export function useChatSessionState({
|
||||
|
||||
if (sessionChanged) {
|
||||
setTokenBudget(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setCurrentSessionId(selectedSession.id);
|
||||
if (provider === 'cursor') {
|
||||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||||
}
|
||||
setCurrentSessionId(selectedSessionId);
|
||||
|
||||
// Check session status
|
||||
if (ws) {
|
||||
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
||||
}
|
||||
// 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.
|
||||
subscribeToSelectedSession();
|
||||
|
||||
lastLoadedSessionKeyRef.current = sessionKey;
|
||||
|
||||
// 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 || '',
|
||||
sessionStore.fetchFromServer(selectedSessionId, {
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
offset: 0,
|
||||
}).then(slot => {
|
||||
@@ -516,11 +526,12 @@ export function useChatSessionState({
|
||||
setIsLoadingSessionMessages(false);
|
||||
});
|
||||
}, [
|
||||
pendingViewSessionRef,
|
||||
resetStreamingState,
|
||||
selectedProject,
|
||||
selectedSession?.id,
|
||||
sendMessage,
|
||||
statusCheckSentAtRef,
|
||||
lastSeqRef,
|
||||
ws,
|
||||
sessionStore,
|
||||
]);
|
||||
@@ -531,15 +542,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 +564,7 @@ export function useChatSessionState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
sessionStore,
|
||||
isLoading,
|
||||
isProcessing,
|
||||
]);
|
||||
|
||||
// Search navigation target
|
||||
@@ -585,13 +590,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 +666,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 +681,7 @@ export function useChatSessionState({
|
||||
}
|
||||
};
|
||||
fetchInitialTokenUsage();
|
||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
}, [selectedProject, selectedSession?.id]);
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
||||
@@ -726,16 +720,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 +741,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 +753,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 +796,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 +817,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;
|
||||
|
||||
@@ -11,7 +11,7 @@ export function decodeHtmlEntities(text: string) {
|
||||
export function normalizeInlineCodeFences(text: string) {
|
||||
if (!text || typeof text !== 'string') return text;
|
||||
try {
|
||||
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
|
||||
return text.replace(/```[ \t]*([^\n\r]+?)[ \t]*```/g, '`$1`');
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -192,66 +201,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;
|
||||
}
|
||||
|
||||
@@ -268,7 +269,7 @@ function ChatInterface({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalEscape, { capture: true });
|
||||
};
|
||||
}, [canAbortSession, handleAbortSession, isLoading]);
|
||||
}, [canAbortSession, handleAbortSession]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -315,6 +316,7 @@ function ChatInterface({
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
isLoadingSessionMessages={isLoadingSessionMessages}
|
||||
isProcessing={isProcessing}
|
||||
chatMessages={chatMessages}
|
||||
selectedSession={selectedSession}
|
||||
currentSessionId={currentSessionId}
|
||||
@@ -363,10 +365,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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'luc
|
||||
|
||||
import { useVoiceInput } from '../../hooks/useVoiceInput';
|
||||
import { useVoiceAvailable } from '../../hooks/useVoiceAvailable';
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||
import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
import type { PendingPermissionRequest, PermissionMode } from '../../types/types';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputHeader,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
} from '../../../../shared/view/ui';
|
||||
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import ActivityIndicator from './ActivityIndicator';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import VoiceInputButton from './VoiceInputButton';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
@@ -55,10 +56,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;
|
||||
@@ -110,10 +110,9 @@ export default function ChatComposer({
|
||||
pendingPermissionRequests,
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
claudeStatus,
|
||||
activity,
|
||||
isLoading,
|
||||
onAbortSession,
|
||||
provider,
|
||||
permissionMode,
|
||||
onModeSwitch,
|
||||
tokenBudget,
|
||||
@@ -201,12 +200,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user