Files
claudecodeui/src/components/chat/hooks/useChatComposerState.ts
Simos Mikelatos 45e71a0e73 feat: introduce notification system and claude notifications (#450)
* feat: introduce notification system and claude notifications

* fix(sw): prevent caching of API requests and WebSocket upgrades

* default to false for webpush notifications and translations for the button

* fix: notifications orchestrator and add a notification when  first enabled

* fix: remove unused state update and dependency in settings controller hook

* fix: show notifications settings tab

* fix: add notifications for response completion for all providers

* feat: show session name in notification and don't reload tab on clicking
--- the notification

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Haileyesus <something@gmail.com>
2026-03-13 18:59:09 +03:00

1036 lines
31 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type {
ChangeEvent,
ClipboardEvent,
Dispatch,
FormEvent,
KeyboardEvent,
MouseEvent,
SetStateAction,
TouchEvent,
} from 'react';
import { useDropzone } from 'react-dropzone';
import { authenticatedFetch } from '../../../utils/api';
import { thinkingModes } from '../constants/thinkingModes';
import { grantClaudeToolPermission } from '../utils/chatPermissions';
import { safeLocalStorage } from '../utils/chatStorage';
import type {
ChatMessage,
PendingPermissionRequest,
PermissionMode,
} from '../types/types';
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
import { escapeRegExp } from '../utils/chatFormatting';
import { useFileMentions } from './useFileMentions';
import { type SlashCommand, useSlashCommands } from './useSlashCommands';
type PendingViewSession = {
sessionId: string | null;
startedAt: number;
};
interface UseChatComposerStateArgs {
selectedProject: Project | null;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
provider: SessionProvider;
permissionMode: PermissionMode | string;
cyclePermissionMode: () => void;
cursorModel: string;
claudeModel: string;
codexModel: string;
geminiModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
sendMessage: (message: unknown) => void;
sendByCtrlEnter?: boolean;
onSessionActive?: (sessionId?: string | null) => void;
onSessionProcessing?: (sessionId?: string | null) => void;
onInputFocusChange?: (focused: boolean) => void;
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
onShowSettings?: () => void;
pendingViewSessionRef: { current: PendingViewSession | null };
scrollToBottom: () => void;
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setSessionMessages?: Dispatch<SetStateAction<any[]>>;
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[]>>;
}
interface MentionableFile {
name: string;
path: string;
}
interface CommandExecutionResult {
type: 'builtin' | 'custom';
action?: string;
data?: any;
content?: string;
hasBashCommands?: boolean;
hasFileIncludes?: boolean;
}
const createFakeSubmitEvent = () => {
return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;
};
const isTemporarySessionId = (sessionId: string | null | undefined) =>
Boolean(sessionId && sessionId.startsWith('new-session-'));
const getNotificationSessionSummary = (
selectedSession: ProjectSession | null,
fallbackInput: string,
): string | null => {
const sessionSummary = selectedSession?.summary || selectedSession?.name || selectedSession?.title;
if (typeof sessionSummary === 'string' && sessionSummary.trim()) {
const normalized = sessionSummary.replace(/\s+/g, ' ').trim();
return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
}
const normalizedFallback = fallbackInput.replace(/\s+/g, ' ').trim();
if (!normalizedFallback) {
return null;
}
return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback;
};
export function useChatComposerState({
selectedProject,
selectedSession,
currentSessionId,
provider,
permissionMode,
cyclePermissionMode,
cursorModel,
claudeModel,
codexModel,
geminiModel,
isLoading,
canAbortSession,
tokenBudget,
sendMessage,
sendByCtrlEnter,
onSessionActive,
onSessionProcessing,
onInputFocusChange,
onFileOpen,
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
setChatMessages,
setSessionMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setIsUserScrolledUp,
setPendingPermissionRequests,
}: UseChatComposerStateArgs) {
const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
}
return '';
});
const [attachedImages, setAttachedImages] = useState<File[]>([]);
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const [thinkingMode, setThinkingMode] = useState('none');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputHighlightRef = useRef<HTMLDivElement>(null);
const handleSubmitRef = useRef<
((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null
>(null);
const inputValueRef = useRef(input);
const handleBuiltInCommand = useCallback(
(result: CommandExecutionResult) => {
const { action, data } = result;
switch (action) {
case 'clear':
setChatMessages([]);
setSessionMessages?.([]);
break;
case 'help':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: data.content,
timestamp: Date.now(),
},
]);
break;
case 'model':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
timestamp: Date.now(),
},
]);
break;
case 'cost': {
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
setChatMessages((previous) => [
...previous,
{ type: 'assistant', content: costMessage, timestamp: Date.now() },
]);
break;
}
case 'status': {
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
setChatMessages((previous) => [
...previous,
{ type: 'assistant', content: statusMessage, timestamp: Date.now() },
]);
break;
}
case 'memory':
if (data.error) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `⚠️ ${data.message}`,
timestamp: Date.now(),
},
]);
} else {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
timestamp: Date.now(),
},
]);
if (data.exists && onFileOpen) {
onFileOpen(data.path);
}
}
break;
case 'config':
onShowSettings?.();
break;
case 'rewind':
if (data.error) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `⚠️ ${data.message}`,
timestamp: Date.now(),
},
]);
} else {
setChatMessages((previous) => previous.slice(0, -data.steps * 2));
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `${data.message}`,
timestamp: Date.now(),
},
]);
}
break;
default:
console.warn('Unknown built-in command action:', action);
}
},
[onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
);
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
const { content, hasBashCommands } = result;
if (hasBashCommands) {
const confirmed = window.confirm(
'This command contains bash commands that will be executed. Do you want to proceed?',
);
if (!confirmed) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '❌ Command execution cancelled',
timestamp: Date.now(),
},
]);
return;
}
}
const commandContent = content || '';
setInput(commandContent);
inputValueRef.current = commandContent;
// Defer submit to next tick so the command text is reflected in UI before dispatching.
setTimeout(() => {
if (handleSubmitRef.current) {
handleSubmitRef.current(createFakeSubmitEvent());
}
}, 0);
}, [setChatMessages]);
const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string) => {
if (!command || !selectedProject) {
return;
}
try {
const effectiveInput = rawInput ?? input;
const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
const args =
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
const context = {
projectPath: selectedProject.fullPath || selectedProject.path,
projectName: selectedProject.name,
sessionId: currentSessionId,
provider,
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
tokenUsage: tokenBudget,
};
const response = await authenticatedFetch('/api/commands/execute', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
commandName: command.name,
commandPath: command.path,
args,
context,
}),
});
if (!response.ok) {
let errorMessage = `Failed to execute command (${response.status})`;
try {
const errorData = await response.json();
errorMessage = errorData?.message || errorData?.error || errorMessage;
} catch {
// Ignore JSON parse failures and use fallback message.
}
throw new Error(errorMessage);
}
const result = (await response.json()) as CommandExecutionResult;
if (result.type === 'builtin') {
handleBuiltInCommand(result);
setInput('');
inputValueRef.current = '';
} else if (result.type === 'custom') {
await handleCustomCommand(result);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error executing command:', error);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `Error executing command: ${message}`,
timestamp: Date.now(),
},
]);
}
},
[
claudeModel,
codexModel,
currentSessionId,
cursorModel,
geminiModel,
handleBuiltInCommand,
handleCustomCommand,
input,
provider,
selectedProject,
setChatMessages,
tokenBudget,
],
);
const {
slashCommands,
slashCommandsCount,
filteredCommands,
frequentCommands,
commandQuery,
showCommandMenu,
selectedCommandIndex,
resetCommandMenuState,
handleCommandSelect,
handleToggleCommandMenu,
handleCommandInputChange,
handleCommandMenuKeyDown,
} = useSlashCommands({
selectedProject,
input,
setInput,
textareaRef,
onExecuteCommand: executeCommand,
});
const {
showFileDropdown,
filteredFiles,
selectedFileIndex,
renderInputWithMentions,
selectFile,
setCursorPosition,
handleFileMentionsKeyDown,
} = useFileMentions({
selectedProject,
input,
setInput,
textareaRef,
});
const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => {
if (!inputHighlightRef.current || !target) {
return;
}
inputHighlightRef.current.scrollTop = target.scrollTop;
inputHighlightRef.current.scrollLeft = target.scrollLeft;
}, []);
const handleImageFiles = useCallback((files: File[]) => {
const validFiles = files.filter((file) => {
try {
if (!file || typeof file !== 'object') {
console.warn('Invalid file object:', file);
return false;
}
if (!file.type || !file.type.startsWith('image/')) {
return false;
}
if (!file.size || file.size > 5 * 1024 * 1024) {
const fileName = file.name || 'Unknown file';
setImageErrors((previous) => {
const next = new Map(previous);
next.set(fileName, 'File too large (max 5MB)');
return next;
});
return false;
}
return true;
} catch (error) {
console.error('Error validating file:', error, file);
return false;
}
});
if (validFiles.length > 0) {
setAttachedImages((previous) => [...previous, ...validFiles].slice(0, 5));
}
}, []);
const handlePaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(event.clipboardData.items);
items.forEach((item) => {
if (!item.type.startsWith('image/')) {
return;
}
const file = item.getAsFile();
if (file) {
handleImageFiles([file]);
}
});
if (items.length === 0 && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
if (imageFiles.length > 0) {
handleImageFiles(imageFiles);
}
}
},
[handleImageFiles],
);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
},
maxSize: 5 * 1024 * 1024,
maxFiles: 5,
onDrop: handleImageFiles,
noClick: true,
noKeyboard: true,
});
const handleSubmit = useCallback(
async (
event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,
) => {
event.preventDefault();
const currentInput = inputValueRef.current;
if (!currentInput.trim() || isLoading || !selectedProject) {
return;
}
// Intercept slash commands: if input starts with /commandName, execute as command with args
const trimmedInput = currentInput.trim();
if (trimmedInput.startsWith('/')) {
const firstSpace = trimmedInput.indexOf(' ');
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
if (matchedCommand) {
executeCommand(matchedCommand, trimmedInput);
setInput('');
inputValueRef.current = '';
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
resetCommandMenuState();
setIsTextareaExpanded(false);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
return;
}
}
let messageContent = currentInput;
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
}
let uploadedImages: unknown[] = [];
if (attachedImages.length > 0) {
const formData = new FormData();
attachedImages.forEach((file) => {
formData.append('images', file);
});
try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {
method: 'POST',
headers: {},
body: formData,
});
if (!response.ok) {
throw new Error('Failed to upload images');
}
const result = await response.json();
uploadedImages = result.images;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Image upload failed:', error);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: `Failed to upload images: ${message}`,
timestamp: new Date(),
},
]);
return;
}
}
const userMessage: ChatMessage = {
type: 'user',
content: currentInput,
images: uploadedImages as any,
timestamp: new Date(),
};
setChatMessages((previous) => [...previous, userMessage]);
setIsLoading(true); // Processing banner starts
setCanAbortSession(true);
setClaudeStatus({
text: 'Processing',
tokens: 0,
can_interrupt: true,
});
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100);
const effectiveSessionId =
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
if (!effectiveSessionId && !selectedSession?.id) {
if (typeof window !== 'undefined') {
// Reset stale pending IDs from previous interrupted runs before creating a new one.
sessionStorage.removeItem('pendingSessionId');
}
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
onSessionActive?.(sessionToActivate);
if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
onSessionProcessing?.(effectiveSessionId);
}
const getToolsSettings = () => {
try {
const settingsKey =
provider === 'cursor'
? 'cursor-tools-settings'
: provider === 'codex'
? 'codex-settings'
: provider === 'gemini'
? 'gemini-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
return JSON.parse(savedSettings);
}
} catch (error) {
console.error('Error loading tools settings:', error);
}
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
};
};
const toolsSettings = getToolsSettings();
const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';
const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);
if (provider === 'cursor') {
sendMessage({
type: 'cursor-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: cursorModel,
skipPermissions: toolsSettings?.skipPermissions || false,
sessionSummary,
toolsSettings,
},
});
} else if (provider === 'codex') {
sendMessage({
type: 'codex-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: codexModel,
sessionSummary,
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
},
});
} else if (provider === 'gemini') {
sendMessage({
type: 'gemini-command',
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: resolvedProjectPath,
projectPath: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
model: geminiModel,
sessionSummary,
permissionMode,
toolsSettings,
},
});
} else {
sendMessage({
type: 'claude-command',
command: messageContent,
options: {
projectPath: resolvedProjectPath,
cwd: resolvedProjectPath,
sessionId: effectiveSessionId,
resume: Boolean(effectiveSessionId),
toolsSettings,
permissionMode,
model: claudeModel,
sessionSummary,
images: uploadedImages,
},
});
}
setInput('');
inputValueRef.current = '';
resetCommandMenuState();
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
setThinkingMode('none');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
},
[
selectedSession,
attachedImages,
claudeModel,
codexModel,
currentSessionId,
cursorModel,
executeCommand,
geminiModel,
isLoading,
onSessionActive,
onSessionProcessing,
pendingViewSessionRef,
permissionMode,
provider,
resetCommandMenuState,
scrollToBottom,
selectedProject,
sendMessage,
setCanAbortSession,
setChatMessages,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,
slashCommands,
thinkingMode,
],
);
useEffect(() => {
handleSubmitRef.current = handleSubmit;
}, [handleSubmit]);
useEffect(() => {
inputValueRef.current = input;
}, [input]);
useEffect(() => {
if (!selectedProject) {
return;
}
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
setInput((previous) => {
const next = previous === savedInput ? previous : savedInput;
inputValueRef.current = next;
return next;
});
}, [selectedProject?.name]);
useEffect(() => {
if (!selectedProject) {
return;
}
if (input !== '') {
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
} else {
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
}, [input, selectedProject]);
useEffect(() => {
if (!textareaRef.current) {
return;
}
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
setIsTextareaExpanded(expanded);
}, [input]);
useEffect(() => {
if (!textareaRef.current || input.trim()) {
return;
}
textareaRef.current.style.height = 'auto';
setIsTextareaExpanded(false);
}, [input]);
const handleInputChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>) => {
const newValue = event.target.value;
const cursorPos = event.target.selectionStart;
setInput(newValue);
inputValueRef.current = newValue;
setCursorPosition(cursorPos);
if (!newValue.trim()) {
event.target.style.height = 'auto';
setIsTextareaExpanded(false);
resetCommandMenuState();
return;
}
handleCommandInputChange(newValue, cursorPos);
},
[handleCommandInputChange, resetCommandMenuState, setCursorPosition],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (handleCommandMenuKeyDown(event)) {
return;
}
if (handleFileMentionsKeyDown(event)) {
return;
}
if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
event.preventDefault();
cyclePermissionMode();
return;
}
if (event.key === 'Enter') {
if (event.nativeEvent.isComposing) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {
event.preventDefault();
handleSubmit(event);
} else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) {
event.preventDefault();
handleSubmit(event);
}
}
},
[
cyclePermissionMode,
handleCommandMenuKeyDown,
handleFileMentionsKeyDown,
handleSubmit,
sendByCtrlEnter,
showCommandMenu,
showFileDropdown,
],
);
const handleTextareaClick = useCallback(
(event: MouseEvent<HTMLTextAreaElement>) => {
setCursorPosition(event.currentTarget.selectionStart);
},
[setCursorPosition],
);
const handleTextareaInput = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
setCursorPosition(target.selectionStart);
syncInputOverlayScroll(target);
const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);
setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);
},
[setCursorPosition, syncInputOverlayScroll],
);
const handleClearInput = useCallback(() => {
setInput('');
inputValueRef.current = '';
resetCommandMenuState();
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.focus();
}
setIsTextareaExpanded(false);
}, [resetCommandMenuState]);
const handleAbortSession = useCallback(() => {
if (!canAbortSession) {
return;
}
const pendingSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
const cursorSessionId =
typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;
const candidateSessionIds = [
currentSessionId,
pendingViewSessionRef.current?.sessionId || null,
pendingSessionId,
provider === 'cursor' ? cursorSessionId : null,
selectedSession?.id || null,
];
const targetSessionId =
candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null;
if (!targetSessionId) {
console.warn('Abort requested but no concrete session ID is available yet.');
return;
}
sendMessage({
type: 'abort-session',
sessionId: targetSessionId,
provider,
});
}, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);
const handleTranscript = useCallback((text: string) => {
if (!text.trim()) {
return;
}
setInput((previousInput) => {
const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;
inputValueRef.current = newInput;
setTimeout(() => {
if (!textareaRef.current) {
return;
}
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);
}, 0);
return newInput;
});
}, []);
const handleGrantToolPermission = useCallback(
(suggestion: { entry: string; toolName: string }) => {
if (!suggestion || provider !== 'claude') {
return { success: false };
}
return grantClaudeToolPermission(suggestion.entry);
},
[provider],
);
const handlePermissionDecision = useCallback(
(
requestIds: string | string[],
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
) => {
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
const validIds = ids.filter(Boolean);
if (validIds.length === 0) {
return;
}
validIds.forEach((requestId) => {
sendMessage({
type: 'claude-permission-response',
requestId,
allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput,
message: decision?.message,
rememberEntry: decision?.rememberEntry,
});
});
setPendingPermissionRequests((previous) => {
const next = previous.filter((request) => !validIds.includes(request.requestId));
if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
},
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
);
const [isInputFocused, setIsInputFocused] = useState(false);
const handleInputFocusChange = useCallback(
(focused: boolean) => {
setIsInputFocused(focused);
onInputFocusChange?.(focused);
},
[onInputFocusChange],
);
return {
input,
setInput,
textareaRef,
inputHighlightRef,
isTextareaExpanded,
thinkingMode,
setThinkingMode,
slashCommandsCount,
filteredCommands,
frequentCommands,
commandQuery,
showCommandMenu,
selectedCommandIndex,
resetCommandMenuState,
handleCommandSelect,
handleToggleCommandMenu,
showFileDropdown,
filteredFiles: filteredFiles as MentionableFile[],
selectedFileIndex,
renderInputWithMentions,
selectFile,
attachedImages,
setAttachedImages,
uploadingImages,
imageErrors,
getRootProps,
getInputProps,
isDragActive,
openImagePicker: open,
handleSubmit,
handleInputChange,
handleKeyDown,
handlePaste,
handleTextareaClick,
handleTextareaInput,
syncInputOverlayScroll,
handleClearInput,
handleAbortSession,
handleTranscript,
handlePermissionDecision,
handleGrantToolPermission,
handleInputFocusChange,
isInputFocused,
};
}