import React from 'react'; import { flushSync } from 'react-dom'; import { isCapacitorApp } from '@/lib/platform'; import { Textarea } from '@/components/ui/textarea'; import { ComposerDictation } from '@/components/dictation/ComposerDictation'; // sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; import { useInputStore } from '@/sync/input-store'; import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT, getUnsupportedAttachmentInputs, type AttachmentInputModality, } from '@/sync/attachment-files'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; import { useDirectorySync, useUserMessageHistory } from '@/sync/sync-context'; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; import { appendInlineComments } from '@/lib/messages/inlineComments'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { startReviewFlow } from '@/lib/reviewFlow'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { createChatDraftIdentity, getChatDraftIdentityKey, readChatDraft, subscribeChatDraftDeletion, writeChatDraft, type ChatDraftIdentity, type ChatDraftSnapshot, } from '@/lib/chatDraftPersistence'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; import ToolOutputDialog from './message/ToolOutputDialog'; import type { ToolPopupContent } from './message/types'; import { QueuedMessageChips } from './QueuedMessageChips'; import { AutoReviewBanner } from './AutoReviewBanner'; import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete'; import { CommandAutocomplete, type CommandAutocompleteHandle, type CommandInfo } from './CommandAutocomplete'; import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete'; import { SnippetAutocomplete, type SnippetAutocompleteHandle } from './SnippetAutocomplete'; import { cn, formatDirectoryName, isMacOS } from '@/lib/utils'; import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { PendingChangesBar } from './PendingChangesBar'; import { useChatSurfaceMode } from './useChatSurfaceMode'; import { MobileAgentButton } from './MobileAgentButton'; import { MobileModelButton } from './MobileModelButton'; import { MobileSessionStatusBar, MobileSessionPanelTrigger } from './MobileSessionStatusBar'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; // useMessageStore removed — messages now come from sync system import { isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { StopIcon } from '@/components/icons/StopIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog'; import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog'; import { Icon } from "@/components/icon/Icon"; import { DraftPresetChips } from './DraftPresetChips'; import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory'; import { opencodeClient } from '@/lib/opencode/client'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { usePermissionStore } from '@/stores/permissionStore'; import { togglePermissionAutoAccept } from './permissionAutoAccept'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; import { fetchResponseStyleInstruction } from '@/lib/responseStyle'; import { wrapSystemReminder } from '@/lib/systemReminder'; import { getSyncMessages } from '@/sync/sync-refs'; import { EMPTY_REVERTED_MESSAGE_DOCK_STATE, buildRevertedMessageDockState, type RevertedMessageDockState } from './revertedMessageDockState'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; import { isSyntheticPart } from '@/lib/messages/synthetic'; import { buildHighlightParts, mentionRangesToHighlightRanges, tokenizeMarkdown, type HighlightRange, type MentionRange, } from './composerHighlight'; import { highlightFencedCode } from './composerCodeHighlight'; import { assignImageAttachmentFilenames, buildAttachmentCitationText, findAttachmentCitationRanges, } from './attachmentCitations'; import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState'; import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip'; import { SessionGoalRow } from '@/components/chat/SessionGoalRow'; import { SessionGoalButton, SessionGoalObjectiveCounter } from '@/components/chat/SessionGoalButton'; import type { Part } from '@opencode-ai/sdk/v2/client'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; const FILE_MENTION_TOKEN = /^@[^\s]+$/; // Single-line URL pasted over a selection becomes a markdown link. const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i; const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g; const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500; const getChatDraftSnapshotSignature = (text: string, confirmedMentions: Iterable): string => ( `${text}\u0000${[...confirmedMentions].sort().join('\u0000')}` ); const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const VS_CODE_DROP_DATA_TYPES = [ 'CodeFiles', 'codefiles', 'application/vnd.code.tree', 'application/vnd.code.tree.explorer', 'text/uri-list', 'text/plain', ]; const renameFileForAttachmentCitation = (file: File, filename: string): File => { if (file.name === filename) { return file; } return new File([file], filename, { type: file.type, lastModified: file.lastModified, }); }; const buildImagePasteInsertion = (pastedText: string, citationText: string): string => { const text = pastedText; if (!text) { return citationText; } return `${text}${/\s$/.test(text) ? '' : ' '}${citationText}`; }; const getInsertedTextFromChange = (previousValue: string, nextValue: string): string => { if (previousValue === nextValue) { return ''; } let prefixLength = 0; while ( prefixLength < previousValue.length && prefixLength < nextValue.length && previousValue[prefixLength] === nextValue[prefixLength] ) { prefixLength += 1; } let previousSuffix = previousValue.length; let nextSuffix = nextValue.length; while ( previousSuffix > prefixLength && nextSuffix > prefixLength && previousValue[previousSuffix - 1] === nextValue[nextSuffix - 1] ) { previousSuffix -= 1; nextSuffix -= 1; } return nextValue.slice(prefixLength, nextSuffix); }; const getFileMentionInputSourceForInsertedText = (insertedText: string): FileMentionAutocompleteInputSource => ( insertedText.includes('@') ? 'paste' : 'manual' ); const withInlineInsertionBoundaries = (content: string, before: string, after: string): string => { if (!content) { return content; } const needsLeadingSpace = before.length > 0 && !/\s$/.test(before) && !/^\s/.test(content) && !/[([{]$/.test(before); const needsTrailingSpace = after.length > 0 && !/\s$/.test(content) && !/^\s/.test(after) && !/^[\])}.,;:!?]/.test(after); return `${needsLeadingSpace ? ' ' : ''}${content}${needsTrailingSpace ? ' ' : ''}`; }; const collectInlineSkillMentions = (text: string, skillNames: Set): string[] => { const mentions: string[] = []; INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0; let match: RegExpExecArray | null; while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) { const name = match[2] || ''; if (!skillNames.has(name) || mentions.includes(name)) { continue; } mentions.push(name); } return mentions; }; const buildSkillMentionInstruction = (skillNames: string[]): string | null => { if (skillNames.length === 0) return null; const formatted = skillNames.map((name) => `/${name}`).join(', '); return `The user explicitly mentioned these skills in their message: ${formatted}. Use the corresponding skill tool when it is relevant to accomplishing the user's request.`; }; const hasUserMessages = (sessionId: string, directory?: string) => { return getSyncMessages(sessionId, directory).some((message) => message.role === 'user'); }; const getRevertedPreview = (parts: Part[], fallback: string): string => { const text = parts .filter((part) => part.type === 'text' && !isSyntheticPart(part)) .map((part) => { const record = part as Record; return typeof record.text === 'string' ? record.text : typeof record.content === 'string' ? record.content : ''; }) .join('\n') .replace(/\s+/g, ' ') .trim(); if (text) return text; const filePart = parts.find((part) => part.type === 'file') as (Part & { filename?: string }) | undefined; return filePart?.filename ? `[${filePart.filename}]` : fallback; }; const FILE_URI_PREFIX = 'file://'; const encodeFilePath = (filepath: string): string => { let normalized = filepath.replace(/\\/g, '/'); if (/^[A-Za-z]:/.test(normalized)) { normalized = `/${normalized}`; } return normalized .split('/') .map((segment, index) => { if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment; return encodeURIComponent(segment); }) .join('/'); }; const toServerFileUrl = (filepath: string): string => { const normalized = filepath.replace(/\\/g, '/').trim(); if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) { return normalized; } return `file://${encodeFilePath(normalized)}`; }; const isLikelyAbsolutePath = (value: string): boolean => ( value.startsWith('/') || value.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(value) ); const toLikelyFileDropReference = (value: string): string | null => { const trimmed = value.trim().replace(/^['"]+|['"]+$/g, ''); if (!trimmed) { return null; } if (/[\r\n]/.test(trimmed)) { return null; } if (trimmed.toLowerCase().startsWith(FILE_URI_PREFIX)) { return trimmed; } if (isLikelyAbsolutePath(trimmed)) { return trimmed; } return null; }; const collectStringLeaves = (input: unknown, output: Set, depth = 0): void => { if (depth > 6 || input == null) { return; } if (typeof input === 'string') { output.add(input); return; } if (Array.isArray(input)) { for (const item of input) { collectStringLeaves(item, output, depth + 1); } return; } if (typeof input !== 'object') { return; } for (const value of Object.values(input)) { collectStringLeaves(value, output, depth + 1); } }; const parseDroppedFileReferences = (rawPayload: string): string[] => { const extracted = new Set(); const addCandidatesFromText = (value: string): void => { const direct = toLikelyFileDropReference(value); if (direct) { extracted.add(direct); return; } for (const line of value.split(/\r?\n/)) { const candidate = toLikelyFileDropReference(line); if (candidate) { extracted.add(candidate); } } }; addCandidatesFromText(rawPayload); try { const parsed = JSON.parse(rawPayload) as unknown; const leaves = new Set(); collectStringLeaves(parsed, leaves); for (const leaf of leaves) { addCandidatesFromText(leaf); } } catch { // Ignore non-JSON payloads. } return Array.from(extracted); }; const normalizePath = (value?: string | null): string | null => { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } const normalized = trimmed.replace(/\\/g, '/'); if (normalized === '/') { return '/'; } return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized; }; const getProjectDisplayLabel = (project: { label?: string; path: string }): string => { const label = project.label?.trim(); if (label) { return label; } return formatDirectoryName(project.path); }; const renderDraftTitle = (title: string, projectLabel: string | null): React.ReactNode => { if (!projectLabel) return title; const projectIndex = title.indexOf(projectLabel); if (projectIndex === -1) return title; return ( <> {title.slice(0, projectIndex)} {projectLabel} {title.slice(projectIndex + projectLabel.length)} ); }; const getProjectIconColor = (projectColor?: string | null): string | undefined => { if (!projectColor) { return undefined; } return PROJECT_COLOR_MAP[projectColor] ?? undefined; }; const MemoModelControls = React.memo(ModelControls); const MemoComposerDictation = React.memo(ComposerDictation); const MemoMobileAgentButton = React.memo(MobileAgentButton); const MemoMobileModelButton = React.memo(MobileModelButton); const MemoStatusRow = React.memo(StatusRow); type RevertedMessageDockProps = { sessionId: string | null; directory?: string; }; const RevertedMessageDock: React.FC = React.memo(({ sessionId, directory }) => { const { t } = useI18n(); const revertToMessage = useSessionUIStore((s) => s.revertToMessage); const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage); const handleSlashRedo = useSessionUIStore((s) => s.handleSlashRedo); const [restoringId, setRestoringId] = React.useState(null); const [forkingId, setForkingId] = React.useState(null); const [collapsed, setCollapsed] = React.useState(true); const revertedStateRef = React.useRef(EMPTY_REVERTED_MESSAGE_DOCK_STATE); const revertedState = useDirectorySync( React.useCallback((state) => { const next = buildRevertedMessageDockState(state, sessionId, revertedStateRef.current); revertedStateRef.current = next; return next; }, [sessionId]), directory, ); const revertMessageID = revertedState.revertMessageID; const userMessages = React.useMemo( () => revertedState.records.map((record) => record.message), [revertedState], ); const noTextContent = t('chat.revertPopover.noTextContent'); const items = React.useMemo(() => { if (!revertMessageID) return []; return revertedState.records.map((record) => ({ id: record.message.id, text: getRevertedPreview(record.parts, noTextContent), })); }, [noTextContent, revertMessageID, revertedState]); const firstRevertedMessageId = items[0]?.id; React.useEffect(() => { setCollapsed(true); }, [revertMessageID, firstRevertedMessageId]); const handleRestore = React.useCallback(async (messageId: string) => { if (!sessionId || restoringId) return; setRestoringId(messageId); try { const nextMessage = userMessages.find((message) => message.id > messageId); if (nextMessage) { await revertToMessage(sessionId, nextMessage.id, { skipRedoPush: true }); } else { await handleSlashRedo(sessionId, { fullUnrevert: true }); } } finally { setRestoringId(null); } }, [handleSlashRedo, revertToMessage, restoringId, sessionId, userMessages]); const handleFork = React.useCallback(async (messageId: string) => { if (!sessionId || forkingId) return; setForkingId(messageId); try { await forkFromMessage(sessionId, messageId); } finally { setForkingId(null); } }, [forkFromMessage, forkingId, sessionId]); if (!sessionId || items.length === 0) return null; return (
{!collapsed && (
{items.map((item) => (
{item.text}
))}
)}
); }); RevertedMessageDock.displayName = 'RevertedMessageDock'; type ComposerAttachmentControlsProps = { isVSCode: boolean; footerIconButtonClass: string; iconSizeClass: string; handlePickLocalFiles: () => void; openIssuePicker: () => void; openPrPicker: () => void; onOpenSettings?: () => void; onMenuOpenChange?: (open: boolean) => void; /** Mobile: open the attachment bottom sheet instead of the dropdown menu. */ onOpenMobileSheet?: () => void; }; const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) { const { t } = useI18n(); const { isVSCode, footerIconButtonClass, iconSizeClass, handlePickLocalFiles, openIssuePicker, openPrPicker, onOpenSettings, } = props; return (
{props.onOpenMobileSheet ? ( ) : isVSCode ? ( ) : ( { requestAnimationFrame(handlePickLocalFiles); }} > {t('chat.chatInput.actions.attachFiles')} { requestAnimationFrame(openIssuePicker); }} > {t('chat.chatInput.actions.linkGithubIssue')} { requestAnimationFrame(openPrPicker); }} > {t('chat.chatInput.actions.linkGithubPr')} )}
{onOpenSettings ? ( ) : null}
); }, (prev, next) => ( prev.isVSCode === next.isVSCode && prev.footerIconButtonClass === next.footerIconButtonClass && prev.iconSizeClass === next.iconSizeClass && prev.onOpenSettings === next.onOpenSettings && prev.onMenuOpenChange === next.onMenuOpenChange && prev.onOpenMobileSheet === next.onOpenMobileSheet )); type PermissionAutoAcceptButtonProps = { footerIconButtonClass: string; iconSizeClass: string; isInteractive: boolean; permissionAutoAcceptEnabled: boolean; handlePermissionAutoAcceptToggle: () => void; withTooltip?: boolean; }; const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) { const { t } = useI18n(); const { footerIconButtonClass, iconSizeClass, isInteractive, permissionAutoAcceptEnabled, handlePermissionAutoAcceptToggle, withTooltip = false, } = props; const ariaLabel = permissionAutoAcceptEnabled ? t('chat.chatInput.permissionAutoAccept.disable') : t('chat.chatInput.permissionAutoAccept.enable'); const tooltipLabel = permissionAutoAcceptEnabled ? t('chat.chatInput.permissionAutoAccept.on') : t('chat.chatInput.permissionAutoAccept.off'); const button = ( ); if (!withTooltip) { return button; } return ( {button} {tooltipLabel} ); }); type FocusModeButtonProps = { footerIconButtonClass: string; iconSizeClass: string; isExpandedInput: boolean; onToggle: () => void; }; const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; const { t } = useI18n(); return (
{t('chat.chatInput.focusMode.label')} {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
); }); type ComposerActionButtonsProps = { isMobile: boolean; footerIconButtonClass: string; sendIconSizeClass: string; stopIconSizeClass: string; canSend: boolean; canAbort: boolean; hasContent: boolean; currentSessionId: string | null; newSessionDraftOpen: boolean; onPrimaryAction: () => void; onQueueMessage: () => void; onAbort: () => void; }; const ComposerActionButtons = React.memo(function ComposerActionButtons(props: ComposerActionButtonsProps) { const { isMobile, footerIconButtonClass, sendIconSizeClass, stopIconSizeClass, canSend, canAbort, hasContent, currentSessionId, newSessionDraftOpen, onPrimaryAction, onQueueMessage, onAbort, } = props; const { t } = useI18n(); const sendButton = ( ); if (!canAbort) { return sendButton; } return (
{hasContent ? ( ) : null}
); }, (prev, next) => ( prev.isMobile === next.isMobile && prev.footerIconButtonClass === next.footerIconButtonClass && prev.sendIconSizeClass === next.sendIconSizeClass && prev.stopIconSizeClass === next.stopIconSizeClass && prev.canSend === next.canSend && prev.canAbort === next.canAbort && prev.hasContent === next.hasContent && prev.currentSessionId === next.currentSessionId && prev.newSessionDraftOpen === next.newSessionDraftOpen && prev.onPrimaryAction === next.onPrimaryAction && prev.onQueueMessage === next.onQueueMessage && prev.onAbort === next.onAbort )); const appendWithLineBreaks = (base: string, next: string): string => { const separator = !base ? '' : base.endsWith('\n\n') ? '' : base.endsWith('\n') ? '\n' : '\n\n'; const nextWithTrailingBreaks = next.endsWith('\n\n') ? next : next.endsWith('\n') ? `${next}\n` : `${next}\n\n`; return `${base}${separator}${nextWithTrailingBreaks}`; }; const appendInlineText = (base: string, next: string): string => { const nextTrimmed = next.trim(); if (!nextTrimmed) { return base; } if (!base) { return `${nextTrimmed} `; } const separator = /[\s\n]$/.test(base) ? '' : ' '; return `${base}${separator}${nextTrimmed} `; }; interface ChatInputProps { onOpenSettings?: () => void; scrollToBottom?: () => void; } type AutocompleteOverlayPosition = { top: number; left: number; place: 'above' | 'below'; maxHeight: number; }; const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => { const sessionState = useSessionUIStore.getState(); const newSessionDirectory = sessionState.newSessionDraft?.open ? sessionState.newSessionDraft.bootstrapPendingDirectory ?? sessionState.newSessionDraft.directoryOverride : null; const directory = sessionId ? sessionState.getDirectoryForSession(sessionId) ?? sessionState.currentSessionDirectory : newSessionDirectory ?? useDirectoryStore.getState().currentDirectory; return createChatDraftIdentity(getRuntimeKey(), directory, sessionId); }; const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom }) => { const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); const initialDraftIdentityRef = React.useRef(null); const initialDraftSnapshotRef = React.useRef({ text: '', confirmedMentions: new Set() }); const [message, setMessage] = React.useState(() => { const sessionId = useSessionUIStore.getState().currentSessionId; const identity = resolveChatDraftIdentity(sessionId); const snapshot = readChatDraft(identity); initialDraftIdentityRef.current = identity; initialDraftSnapshotRef.current = snapshot; if (snapshot.text) { initialDraftRef.current = snapshot.text; } return snapshot.text; }); const confirmedMentionsRef = React.useRef>(initialDraftSnapshotRef.current.confirmedMentions); // Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed) const isConfirmedFilePath = (text: string): boolean => text.includes('/') || text.includes('\\') || text.includes('.') || confirmedMentionsRef.current.has(text); const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); const [isDragging, setIsDragging] = React.useState(false); const [isInternalDrag, setIsInternalDrag] = React.useState(false); const [showFileMention, setShowFileMention] = React.useState(false); const [mentionQuery, setMentionQuery] = React.useState(''); const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false); const [commandQuery, setCommandQuery] = React.useState(''); const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false); const [skillQuery, setSkillQuery] = React.useState(''); const [showSnippetAutocomplete, setShowSnippetAutocomplete] = React.useState(false); const [snippetQuery, setSnippetQuery] = React.useState(''); const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null); const [mobileControlsPanel, setMobileControlsPanel] = React.useState(null); // Mobile pill composer: when the keyboard is closed the composer collapses // into a narrow pill (+ / placeholder / mic) with a round new-session button // beside it. Any interaction expands back into the full composer. The swap // is deliberately INSTANT and synchronized with the keyboard choreography, // so the chat compensates keyboard + composer height in a single motion. const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false); const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false); // Mobile browser / installed PWA: tapping a composer control while the // keyboard is up blurs the textarea first, and the keyboard-resize reflow // moves the control out from under the finger BEFORE the browser // synthesizes the click — the tap dismisses the keyboard but the control's // onClick never fires. Defer the blur-driven state flip so the pinned // composer holds still through the tap; a refocus cancels it. Capacitor // keeps the immediate flip. const mobileBlurTimerRef = React.useRef(null); React.useEffect(() => () => { if (mobileBlurTimerRef.current !== null) { window.clearTimeout(mobileBlurTimerRef.current); } }, []); const [mobileDictationActive, setMobileDictationActive] = React.useState(false); const [mobileAttachMenuOpen, setMobileAttachMenuOpen] = React.useState(false); const [mobileDraftPicker, setMobileDraftPicker] = React.useState<'project' | 'branch' | null>(null); const [mobileDraftPickerQuery, setMobileDraftPickerQuery] = React.useState(''); // True while ANY MobileOverlayPanel is open (sessions sheet, model/agent // panels, pickers...). Opening one closes the keyboard, which must not // collapse the composer into the pill under the overlay. const [mobileOverlayHostBusy, setMobileOverlayHostBusy] = React.useState(false); // Set while an expansion is settling (focus/dictation not yet active) so the // collapse watcher doesn't immediately fold the composer back into the pill. const mobileExpandIntentRef = React.useRef<'focus' | null>(null); // Keyboard restore across overlays: opening an overlay closes the keyboard; // if it was open at that moment, reopen it when the overlay closes. const lastMobileBlurAtRef = React.useRef(0); const restoreKeyboardAfterOverlayRef = React.useRef(false); // Pill ↔ full composer morph: the wrapper FLIP-animates its height between // the two shapes while the swapped content fades in. const composerHandleTouchRef = React.useRef<{ startY: number; fired: boolean } | null>(null); // Message history navigation state (up/down arrow to recall previous messages) const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode const textareaRef = React.useRef(null); const cursorPosRef = React.useRef(0); const previousMessageLengthRef = React.useRef(message.length); const dropZoneRef = React.useRef(null); const dragEnterCountRef = React.useRef(0); const suppressNextFileDropTextInsertRef = React.useRef(false); const suppressNextFileDropTextInsertTimeoutRef = React.useRef | null>(null); const suppressNextFileMentionPasteRef = React.useRef(false); const suppressNextFileMentionPasteTimeoutRef = React.useRef | null>(null); const pendingDroppedAbsolutePathsRef = React.useRef([]); const canAcceptDropRef = React.useRef(false); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const skillRef = React.useRef(null); const snippetRef = React.useRef(null); // Ref to track current message value without triggering re-renders in effects const messageRef = React.useRef(message); const draftPersistTimerRef = React.useRef | null>(null); const skipNextDraftPersistRef = React.useRef(false); const lastPersistedDraftRef = React.useRef>(new Map()); const currentChatDraftIdentityRef = React.useRef(initialDraftIdentityRef.current); const pendingPastedAttachmentFilenamesRef = React.useRef>(new Set()); // TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.) // eslint-disable-next-line @typescript-eslint/no-explicit-any const sendMessage = React.useRef((...args: any[]) => Promise.resolve((useSessionUIStore.getState().sendMessage as (...a: unknown[]) => unknown)(...args)), ).current; const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); const currentDirectory = useEffectiveDirectory() ?? fallbackDirectory; const currentSessionDirectoryForSync = useSessionUIStore( React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]), ); const activeRuntimeKey = getRuntimeKey(); const chatDraftIdentity = React.useMemo( () => createChatDraftIdentity( activeRuntimeKey, currentSessionDirectoryForSync ?? currentDirectory, currentSessionId, ), [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], ); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraftOpen = Boolean(newSessionDraft?.open); const draftPermissionAutoAcceptEnabled = useSessionUIStore((s) => ( s.newSessionDraft?.open ? s.newSessionDraft.permissionAutoAcceptEnabled === true : false )); const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); const attachedFiles = useInputStore((s) => s.attachedFiles); const addAttachedFile = useInputStore((s) => s.addAttachedFile); const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles); const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection); const consumePendingInputText = useInputStore((s) => s.consumePendingInputText); const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit); const setPendingInputText = useInputStore((s) => s.setPendingInputText); const pendingInputText = useInputStore((s) => s.pendingInputText); const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts); const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort); const abortCurrentOperation = React.useCallback( (sessionIdOverride?: string) => sessionActions.abortCurrentOperation(sessionIdOverride ?? currentSessionId ?? ''), [currentSessionId], ); const currentManagementSessionId = currentSessionId; const projects = useProjectsStore((state) => state.projects); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false); const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); const getModelMetadata = useConfigStore((state) => state.getModelMetadata); // Subscribe to both sources read by getModelMetadata so async metadata and provider updates are observed. useConfigStore((state) => state.modelsMetadata); useConfigStore((state) => state.providers); const currentModelMetadata = currentProviderId && currentModelId ? getModelMetadata(currentProviderId, currentModelId) : undefined; const currentVariant = useConfigStore((state) => state.currentVariant); const currentAgentName = useConfigStore((state) => state.currentAgentName); const setAgent = useConfigStore((state) => state.setAgent); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const agents = getVisibleAgents(); const isMobile = useUIStore((state) => state.isMobile); const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); const isExpandedInput = useUIStore((state) => state.isExpandedInput); const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs(); const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent); const cycleAgentShortcut = React.useMemo(() => ( getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined) ), [cycleAgentShortcutOverride]); const { currentTheme } = useThemeSystem(); const chatSearchDirectory = useChatSearchDirectory(); const isGitRepo = useIsGitRepo(currentDirectory); const currentGitStatus = useGitStore((state) => currentDirectory ? state.directories.get(currentDirectory)?.status ?? null : null, ); const ensureGitStatus = useGitStore((state) => state.ensureStatus); const fetchGitStatus = useGitStore((state) => state.fetchStatus); const [showAbortStatus, setShowAbortStatus] = React.useState(false); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); const composerHighlightRef = React.useRef(null); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const [attachmentPreview, setAttachmentPreview] = React.useState({ open: false, title: '', content: '', }); const attachmentCompatibilityRef = React.useRef({ modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`, modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null, attachmentIds: new Set(), }); React.useEffect(() => { const modelKey = `${currentProviderId ?? ''}/${currentModelId ?? ''}`; const inputModalities = currentModelMetadata?.modalities?.input; const modalitySignature = inputModalities?.slice().sort().join(',') ?? null; const previous = attachmentCompatibilityRef.current; const modelChanged = previous.modelKey !== modelKey; const metadataBecameAvailable = previous.modalitySignature === null && modalitySignature !== null; const filesToCheck = modelChanged || metadataBecameAvailable ? attachedFiles : attachedFiles.filter((file) => !previous.attachmentIds.has(file.id)); attachmentCompatibilityRef.current = { modelKey, modalitySignature, attachmentIds: new Set(attachedFiles.map((file) => file.id)), }; if (!inputModalities || filesToCheck.length === 0) return; const incompatibleFiles = getUnsupportedAttachmentInputs(filesToCheck, inputModalities); if (incompatibleFiles.length === 0) return; const unsupportedModalities = Array.from(new Set(incompatibleFiles.map(({ modality }) => modality))); const modalityLabels: Record = { text: t('chat.modelControls.modality.text'), image: t('chat.modelControls.modality.image'), pdf: t('chat.modelControls.modality.pdf'), audio: t('chat.modelControls.modality.audio'), video: t('chat.modelControls.modality.video'), }; const filenames = incompatibleFiles.map(({ attachment }) => attachment.filename); const fileSummary = filenames.length > 3 ? `${filenames.slice(0, 3).join(', ')} (+${filenames.length - 3})` : filenames.join(', '); toast.warning(t('chat.chatInput.toast.unsupportedAttachmentModalities', { model: currentModelMetadata.name ?? currentModelId ?? '', modalities: unsupportedModalities.map((modality) => modalityLabels[modality]).join(', '), files: fileSummary, }), { id: `attachment-modalities:${modelKey}` }); }, [attachedFiles, currentModelId, currentModelMetadata, currentProviderId, t]); const handleShowAttachmentPreview = React.useCallback((content: ToolPopupContent) => { if (!content.image) return; setAttachmentPreview(content); setImagePreviewOpen(true); }, [setImagePreviewOpen]); const handleAttachmentPreviewOpenChange = React.useCallback((open: boolean) => { setAttachmentPreview((prev) => ({ ...prev, open })); setImagePreviewOpen(open); }, [setImagePreviewOpen]); React.useEffect(() => { if (!currentDirectory || !runtimeGit) return; void ensureGitStatus(currentDirectory, runtimeGit); }, [currentDirectory, runtimeGit, ensureGitStatus]); React.useEffect(() => { if (!currentDirectory || !runtimeGit) return; return sessionEvents.onGitRefreshHint((hint) => { if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) return; void fetchGitStatus(currentDirectory, runtimeGit); }); }, [currentDirectory, runtimeGit, fetchGitStatus]); const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => { if (!currentSessionId) return; const directory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || ''; if (!directory) { toast.error(t('diffView.reviewDialog.toast.noSessionDirectory')); return; } setReviewFlowSubmitting(true); try { await startReviewFlow({ originalSessionID: currentSessionId, directory, providerID: execution.providerID, modelID: execution.modelID, agent: execution.agent || undefined, variant: execution.variant || undefined, generateHandoff: execution.generateHandoff, returnAfterHandoffRequest: execution.generateHandoff, autoReview: execution.autoReview, }); setReviewDialogOpen(false); } catch (error) { console.error('[review-flow] failed to start review flow', error); toast.error(error instanceof Error ? error.message : t('diffView.reviewDialog.toast.startFailed')); } finally { setReviewFlowSubmitting(false); } }, [currentSessionId, currentDirectory, t]); const isDesktopExpanded = isExpandedInput && !isMobile; // Mobile fullscreen composer (entered via the drag handle's swipe-up). const isMobileExpanded = isExpandedInput && isMobile; const isComposerExpanded = isDesktopExpanded || isMobileExpanded; // Rounder composer on mobile (touch UI reads better with a softer corner). const chatInputRadius = isMobile ? '1.5rem' : 'var(--radius-xl)'; const useCompactChatPlaceholder = isMobile || isNarrowComposer; React.useEffect(() => { const element = dropZoneRef.current; if (!element) return; const updateWidth = (width: number) => { const next = width > 0 && width < COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH; setIsNarrowComposer((prev) => (prev === next ? prev : next)); }; updateWidth(element.clientWidth); if (typeof ResizeObserver === 'undefined') { const handleResize = () => updateWidth(element.clientWidth); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); } const observer = new ResizeObserver((entries) => { updateWidth(entries[0]?.contentRect.width ?? element.clientWidth); }); observer.observe(element); return () => observer.disconnect(); }, []); const knownAgentNames = React.useMemo( () => new Set(agents.map((agent) => agent.name.toLowerCase())), [agents] ); const knownAgentNamesRef = React.useRef(knownAgentNames); knownAgentNamesRef.current = knownAgentNames; // Known slash-invocations (commands + skills + built-ins) used to highlight // matching /tokens in the composer, the same way confirmed @files are. const availableCommands = useCommandsStore((s) => s.commands); const availableSkills = useSkillsStore((s) => s.skills); const knownSlashNames = React.useMemo(() => { const names = new Set([ 'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'catch-up', 'debug', 'weigh', 'explore', ]); if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review'); for (const command of availableCommands) names.add(command.name.toLowerCase()); for (const skill of availableSkills) names.add(skill.name.toLowerCase()); return names; }, [availableCommands, availableSkills, isMobile]); // /command and /skill spans (primary color). Only tokens that match a known // command/skill name are highlighted — partial/unknown tokens stay plain. const composerCommandRanges = React.useMemo(() => { if (!message || !message.includes('/') || inputMode === 'shell' || knownSlashNames.size === 0) { return []; } const ranges: HighlightRange[] = []; const slashRegex = /(^|\s)\/([A-Za-z0-9][A-Za-z0-9_-]*)/g; let match: RegExpExecArray | null; while ((match = slashRegex.exec(message)) !== null) { const name = match[2]; if (!knownSlashNames.has(name.toLowerCase())) { continue; } const slashStart = match.index + match[1].length; ranges.push({ start: slashStart, end: slashStart + 1 + name.length, style: 'mentionCommand' }); } return ranges; }, [inputMode, knownSlashNames, message]); // Snippet triggers (#name / #alias). Highlighted like commands once the // trigger matches a known snippet name or alias. const availableSnippets = useSnippetsStore((s) => s.snippets); const knownSnippetTriggers = React.useMemo(() => { const triggers = new Set(); for (const snippet of availableSnippets) { triggers.add(snippet.name.toLowerCase()); for (const alias of snippet.aliases ?? []) triggers.add(alias.toLowerCase()); } return triggers; }, [availableSnippets]); const composerSnippetRanges = React.useMemo(() => { if (!message || !message.includes('#') || inputMode === 'shell' || knownSnippetTriggers.size === 0) { return []; } const ranges: HighlightRange[] = []; const snippetRegex = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g; let match: RegExpExecArray | null; while ((match = snippetRegex.exec(message)) !== null) { const trigger = match[2]; if (!knownSnippetTriggers.has(trigger.toLowerCase())) { continue; } const hashStart = match.index + match[1].length; ranges.push({ start: hashStart, end: hashStart + 1 + trigger.length, style: 'mentionSnippet' }); } return ranges; }, [inputMode, knownSnippetTriggers, message]); // @mention spans (file = blue, agent = green). Computed as character ranges // so they can be merged with markdown highlight ranges in a single overlay. const composerMentionRanges = React.useMemo(() => { if (!message || !message.includes('@') || inputMode === 'shell') { return []; } const ranges: MentionRange[] = []; const mentionRegex = /@([^\s]+)/g; let match: RegExpExecArray | null; while ((match = mentionRegex.exec(message)) !== null) { const full = match[0]; const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, ''); const start = match.index; const end = start + full.length; const charBefore = start > 0 ? message[start - 1] : null; const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore); if (!isBoundary || mention.length === 0) { continue; } if (knownAgentNames.has(mention.toLowerCase())) { ranges.push({ start, end, kind: 'agent' }); } else if (isConfirmedFilePath(mention)) { ranges.push({ start, end, kind: 'file' }); } } return ranges; }, [inputMode, message, knownAgentNames]); const attachmentCitationRanges = React.useMemo(() => { if (!message || !message.includes('[') || inputMode === 'shell' || attachedFiles.length === 0) { return []; } return findAttachmentCitationRanges( message, attachedFiles.map((file) => file.filename), ).map((range) => ({ ...range, style: 'mentionFile' as const, })); }, [attachedFiles, inputMode, message]); // Combined source-mode highlight: markdown syntax + @mentions. Returns null // when there's nothing to highlight so the overlay stays off for plain text. const highlightedComposerContent = React.useMemo(() => { if (!message || inputMode === 'shell') { return null; } const ranges = [ ...tokenizeMarkdown(message), ...highlightFencedCode(message), ...mentionRangesToHighlightRanges(composerMentionRanges), ...composerCommandRanges, ...composerSnippetRanges, ...attachmentCitationRanges, ]; return buildHighlightParts(message, ranges); }, [attachmentCitationRanges, composerCommandRanges, composerSnippetRanges, composerMentionRanges, inputMode, message]); const sanitizeAttachmentsForSend = React.useCallback( (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? []) .map((file) => ({ ...file, dataUrl: file.source === 'server' && file.serverPath ? toServerFileUrl(file.serverPath) : file.dataUrl, })), [], ); const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => { if (!rawText || !rawText.includes('@')) { return { sanitizedText: rawText, attachments: [] }; } const clientDirectory = opencodeClient.getDirectory() || ''; const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, ''); const seenPaths = new Set(); const attachments: AttachedFile[] = []; const mentionRegex = /@([^\s]+)/g; let match: RegExpExecArray | null; while ((match = mentionRegex.exec(rawText)) !== null) { const rawMentionPath = match[1]; const offset = match.index; const original = rawText; const charBefore = offset > 0 ? original[offset - 1] : null; if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) { continue; } const mentionPath = String(rawMentionPath || '') .trim() .replace(/^[`"'<(]+/, '') .replace(/[),.;:!?`"'>]+$/g, ''); if (!mentionPath) { continue; } if (knownAgentNamesRef.current.has(mentionPath.toLowerCase())) { continue; } const looksLikeFilePath = isConfirmedFilePath(mentionPath); if (!looksLikeFilePath) { continue; } const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, ''); if (!normalizedMentionPath) { continue; } const serverPath = mentionPath.startsWith('/') ? mentionPath.replace(/\\/g, '/') : root ? `${root}/${normalizedMentionPath}` : null; if (!serverPath) { continue; } const normalizedServerPath = serverPath.replace(/\/+/g, '/'); if (seenPaths.has(normalizedServerPath)) { continue; } seenPaths.add(normalizedServerPath); const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath; attachments.push({ id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, file: new File([], filename, { type: 'text/plain' }), filename, mimeType: 'text/plain', size: 0, dataUrl: toServerFileUrl(normalizedServerPath), source: 'server', serverPath: normalizedServerPath, }); } return { sanitizedText: rawText, attachments, }; }, [chatSearchDirectory]); const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState(null); const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); // Issue linking state const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); const [prPickerOpen, setPrPickerOpen] = React.useState(false); const [linkedIssue, setLinkedIssue] = React.useState<{ number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string }; } | null>(null); const [linkedPr, setLinkedPr] = React.useState<{ number: number; title: string; url: string; head: string; base: string; includeDiff: boolean; instructionsText: string; contextText: string; author?: { login: string; avatarUrl?: string }; } | null>(null); // Message queue const messageQueueTarget = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory) : null; const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null; const followUpBehavior = useMessageQueueStore((state) => state.followUpBehavior); const queuedMessages = useMessageQueueStore( React.useCallback( (state) => { if (!messageQueueKey) return EMPTY_QUEUE; return state.queuedMessages[messageQueueKey] ?? EMPTY_QUEUE; }, [messageQueueKey] ) ); const addToQueue = useMessageQueueStore((state) => state.addToQueue); const clearQueue = useMessageQueueStore((state) => state.clearQueue); const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); // Inline comment drafts const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory; const inlineDraftTarget = React.useMemo( () => inlineDraftSessionKey && inlineDraftDirectory ? { directory: inlineDraftDirectory, sessionKey: inlineDraftSessionKey } : null, [inlineDraftDirectory, inlineDraftSessionKey], ); const inlineDraftKey = inlineDraftTarget ? getInlineCommentDraftKey(activeRuntimeKey, inlineDraftTarget.directory, inlineDraftTarget.sessionKey) : null; const draftCount = useInlineCommentDraftStore( React.useCallback( (state) => inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []).length : 0, [inlineDraftKey] ) ); const draftSourceKey = useInlineCommentDraftStore( React.useCallback( (state) => { const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : []; let previewConsole = 0; let previewAnnotation = 0; let review = 0; let terminal = 0; for (const draft of drafts) { if (draft.source === 'preview-console') previewConsole += 1; else if (draft.source === 'preview-annotation') previewAnnotation += 1; else if (draft.source === 'terminal') terminal += 1; else review += 1; } return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`; }, [inlineDraftKey] ) ); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); const terminalContextDrafts = terminalContextCount > 0 ? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal') : []; const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => { if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { if (draft.source === source) { removeInlineCommentDraft(inlineDraftTarget, draft.id); } } }, [inlineDraftTarget, removeInlineCommentDraft]); // Review comments are the inline-comment drafts that aren't preview sources. const removeReviewDrafts = React.useCallback(() => { if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') { removeInlineCommentDraft(inlineDraftTarget, draft.id); } } }, [inlineDraftTarget, removeInlineCommentDraft]); // User message history for up/down arrow navigation. // Keep this on a narrow hook instead of full session message records. const userMessageHistory = useUserMessageHistory(currentSessionId ?? ""); // Keep messageRef in sync with message state React.useEffect(() => { messageRef.current = message; }, [message]); React.useEffect(() => { currentChatDraftIdentityRef.current = chatDraftIdentity; }, [chatDraftIdentity]); const persistDraftImmediately = React.useCallback((identity: ChatDraftIdentity | null, draft: string) => { if (!identity) return; const key = getChatDraftIdentityKey(identity); // Only persist confirmed mentions that are actually present in the draft text const activeMentions = new Set(); for (const mention of confirmedMentionsRef.current) { if (draft.includes(`@${mention}`)) { activeMentions.add(mention); } } confirmedMentionsRef.current = activeMentions; const signature = getChatDraftSnapshotSignature(draft, activeMentions); const lastPersisted = lastPersistedDraftRef.current.get(key); if (lastPersisted === signature) { return; } writeChatDraft(identity, draft, activeMentions); lastPersistedDraftRef.current.set(key, signature); }, []); const clearPendingDraftPersist = React.useCallback(() => { if (!draftPersistTimerRef.current) { return; } clearTimeout(draftPersistTimerRef.current); draftPersistTimerRef.current = null; }, []); // Handle initial draft restoration and text selection const hasHandledInitialDraftRef = React.useRef(false); React.useEffect(() => { if (hasHandledInitialDraftRef.current) return; hasHandledInitialDraftRef.current = true; const draft = initialDraftRef.current; if (!draft) return; if (!persistChatDraft) { // Setting disabled - clear the restored draft setMessage(''); writeChatDraft(initialDraftIdentityRef.current, '', []); } else { // Setting enabled - select all text requestAnimationFrame(() => { textareaRef.current?.select(); }); } }, [persistChatDraft]); // Handle identity switching: save the old draft and restore the new runtime/directory/session draft. const prevChatDraftIdentityRef = React.useRef(initialDraftIdentityRef.current); React.useEffect(() => { const previousIdentity = prevChatDraftIdentityRef.current; const previousKey = previousIdentity ? getChatDraftIdentityKey(previousIdentity) : null; const currentKey = chatDraftIdentity ? getChatDraftIdentityKey(chatDraftIdentity) : null; if (previousKey !== currentKey) { prevChatDraftIdentityRef.current = chatDraftIdentity; setInputMode('normal'); clearPendingDraftPersist(); skipNextDraftPersistRef.current = true; if (persistChatDraft) { persistDraftImmediately(previousIdentity, messageRef.current); const nextSnapshot = readChatDraft(chatDraftIdentity); setMessage(nextSnapshot.text); confirmedMentionsRef.current = nextSnapshot.confirmedMentions; if (nextSnapshot.text) { requestAnimationFrame(() => { textareaRef.current?.select(); }); } } else { // Persist disabled: clear input without saving setMessage(''); confirmedMentionsRef.current = new Set(); } } }, [chatDraftIdentity, clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]); React.useEffect(() => subscribeChatDraftDeletion((deletedIdentity) => { const deletedKey = getChatDraftIdentityKey(deletedIdentity); lastPersistedDraftRef.current.set(deletedKey, getChatDraftSnapshotSignature('', [])); const currentIdentity = currentChatDraftIdentityRef.current; if (!currentIdentity || getChatDraftIdentityKey(currentIdentity) !== deletedKey) return; clearPendingDraftPersist(); skipNextDraftPersistRef.current = true; messageRef.current = ''; confirmedMentionsRef.current = new Set(); setMessage(''); }), [clearPendingDraftPersist]); // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); React.useEffect(() => { if (!prevNewSessionDraftOpenRef.current && newSessionDraftOpen) { // New session draft just opened - focus the textarea requestAnimationFrame(() => { if (isMobile) { // On mobile, use preventScroll to avoid viewport jumping textareaRef.current?.focus({ preventScroll: true }); } else { textareaRef.current?.focus(); } }); } prevNewSessionDraftOpenRef.current = newSessionDraftOpen; }, [newSessionDraftOpen, isMobile]); // Persist chat input draft to localStorage per session (only if setting enabled) React.useEffect(() => { if (!persistChatDraft) { clearPendingDraftPersist(); persistDraftImmediately(chatDraftIdentity, ''); return; } if (skipNextDraftPersistRef.current) { skipNextDraftPersistRef.current = false; return; } clearPendingDraftPersist(); const draftSnapshot = message; const identitySnapshot = chatDraftIdentity; draftPersistTimerRef.current = setTimeout(() => { draftPersistTimerRef.current = null; persistDraftImmediately(identitySnapshot, draftSnapshot); }, CHAT_DRAFT_PERSIST_DEBOUNCE_MS); return () => { clearPendingDraftPersist(); }; }, [chatDraftIdentity, clearPendingDraftPersist, message, persistChatDraft, persistDraftImmediately]); React.useEffect(() => { const flushCurrentDraft = () => { clearPendingDraftPersist(); if (persistChatDraft) { persistDraftImmediately(currentChatDraftIdentityRef.current, messageRef.current); } }; const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') flushCurrentDraft(); }; document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('freeze', flushCurrentDraft); window.addEventListener('pagehide', flushCurrentDraft); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('freeze', flushCurrentDraft); window.removeEventListener('pagehide', flushCurrentDraft); flushCurrentDraft(); }; }, [clearPendingDraftPersist, persistChatDraft, persistDraftImmediately]); // Session activity for queue availability and controls const { phase: sessionPhase } = useCurrentSessionActivity(); const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => { if (!currentSessionId) return false; const run = state.runsByOriginalSessionID[currentSessionId]; return run?.status === 'running' && run.runtimeKey === getRuntimeKey(); }, [currentSessionId])); const handleOpenMobilePanel = React.useCallback((panel: MobileControlsPanel) => { if (!isMobile) { return; } // Set the panel state BEFORE blurring: the collapse watcher and the // overlay-host observer must already see the overlay as open when the // keyboard-close lands, otherwise the composer folds into the pill // under the sheet. setMobileControlsPanel(panel); textareaRef.current?.blur(); }, [isMobile]); // Consume pending input text (e.g., from revert action) React.useEffect(() => { if (pendingInputText !== null) { const pending = consumePendingInputText(); if (pending?.text) { if (pending.mode === 'append') { setMessage((prev) => { const next = pending.text; if (!next.trim()) return prev; return appendWithLineBreaks(prev, next); }); } else if (pending.mode === 'append-inline') { setMessage((prev) => appendInlineText(prev, pending.text)); } else { setMessage(pending.text); } // Focus textarea after setting message setTimeout(() => { textareaRef.current?.focus(); }, 0); } } }, [pendingInputText, consumePendingInputText]); const hasContent = message.trim().length > 0 || attachedFiles.length > 0 || hasDrafts; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; const canAbort = sessionPhase !== 'idle'; const getCurrentInputSnapshot = React.useCallback(() => { const currentMessage = textareaRef.current?.value ?? message; return { message: currentMessage, hasContent: currentMessage.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, }; }, [attachedFiles.length, hasDrafts, message]); // Keep a ref to handleSubmit so callbacks don't depend on it. type SubmitOptions = { queuedOnly?: boolean; queuedMessageId?: string; delivery?: 'steer'; /** Submit this text instead of the composer input. Used by preset starter chips: on mobile the collapsed pill has no mounted textarea, so the DOM-first input snapshot would read empty content. */ presetText?: string; }; const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {}); // Add message to queue instead of sending const handleQueueMessage = React.useCallback(() => { const inputSnapshot = getCurrentInputSnapshot(); if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return; const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : []; let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); if (drafts.length > 0) { messageToQueue = appendInlineComments(messageToQueue, drafts); } const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles); addToQueue(messageQueueTarget, { content: messageToQueue, attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, sendConfig: currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId, agent: currentAgentName ?? undefined, variant: currentVariant ?? undefined, } : undefined, }); // Clear input and attachments // Note: confirmedMentionsRef is NOT cleared here because queued messages // are processed later in handleSubmit which reads the ref via extractInlineFileMentions. // The ref is cleared in handleSubmit after all queued messages are sent. setMessage(''); if (attachmentsToQueue.length > 0) { clearAttachedFiles(); } if (!isMobile) { textareaRef.current?.focus(); } }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); setTimeout(() => { textareaRef.current?.focus(); }, 0); }, []); const handleQueuedMessageSend = React.useCallback((messageId: string) => { // Force-sending from the queue during a busy session counts as steer void handleSubmitRef.current({ queuedOnly: true, queuedMessageId: messageId, delivery: 'steer' }); }, []); const handleOpenAgentPanel = React.useCallback(() => { setMobileControlsPanel('agent'); }, []); const handleToggleExpandedInput = React.useCallback(() => { setExpandedInput(!isExpandedInput); }, [isExpandedInput, setExpandedInput]); const openIssuePicker = React.useCallback(() => { setIssuePickerOpen(true); }, []); const openPrPicker = React.useCallback(() => { setPrPickerOpen(true); }, []); const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const inputSnapshot = options?.presetText != null ? { message: options.presetText, hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, } : getCurrentInputSnapshot(); const queuedMessagesToSend = queuedMessageId ? queuedMessages.filter((message) => message.id === queuedMessageId) : queuedMessages; if (queuedOnly && autoReviewRunning) { return; } if (queuedOnly) { if (queuedMessagesToSend.length === 0 || !currentSessionId) return; } else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) { return; } const capturedSendConfig = queuedOnly ? queuedMessagesToSend[0]?.sendConfig : undefined; const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId; const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId; const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName; const variantToSend = capturedSendConfig?.variant ?? currentVariant; if (!providerIdToSend || !modelIdToSend) { console.warn('Cannot send message: provider or model not selected'); return; } // Sending is authoritative: if a question prompt is open, dismiss it // so the prompt cannot linger or strand the session. The dismiss clears // the card instantly (optimistic) and formally rejects the question. // Rejecting unblocks the agent's tool but does NOT end its turn, so a // direct send would race with the still-active run and be silently // discarded by the OpenCode runner. Instead we queue the message; the // queued-message auto-send hook delivers it as the next turn once the // rejected turn winds down and the session returns to idle. This avoids // aborting the turn (which would surface an "aborted" notice). if (currentSessionId && !queuedOnly && autoReviewRunning) { handleQueueMessage(); return; } if (currentSessionId && !queuedOnly) { const dismissedQuestions = await sessionActions.dismissOpenQuestionsForSession(currentSessionId); if (dismissedQuestions) { handleQueueMessage(); return; } } const sendMessageOptions = delivery ? { delivery } : undefined; // Build the primary message (first part) and additional parts let primaryText = ''; let primaryAttachments: AttachedFile[] = []; let agentMentionName: string | undefined; const additionalParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> = []; const availableSkillNames = new Set(useSkillsStore.getState().skills.map((skill) => skill.name)); const mentionedSkillNames: string[] = []; const addMentionedSkills = (text: string) => { for (const name of collectInlineSkillMentions(text, availableSkillNames)) { if (!mentionedSkillNames.includes(name)) mentionedSkillNames.push(name); } }; // Consume any pending synthetic parts (from conflict resolution, etc.) const syntheticParts = consumePendingSyntheticParts(); // Process queued messages first for (let i = 0; i < queuedMessagesToSend.length; i++) { const queuedMsg = queuedMessagesToSend[i]; const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents); const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); addMentionedSkills(queuedText); // Use agent mention from first message that has one if (!agentMentionName && mention?.name) { agentMentionName = mention.name; } if (i === 0) { // First queued message becomes primary primaryText = queuedText; primaryAttachments = [ ...sanitizeAttachmentsForSend(queuedMsg.attachments), ...mentionAttachments, ]; } else { // Subsequent queued messages become additional parts const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments); additionalParts.push({ text: queuedText, attachments: [...queuedAttachments, ...mentionAttachments], }); } } // Add current input (skip for queued-only auto-send) if (!queuedOnly && inputSnapshot.hasContent) { const messageToSend = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents); const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText); const attachmentsToSend = sanitizeAttachmentsForSend(attachedFiles); addMentionedSkills(messageText); if (!agentMentionName && mention?.name) { agentMentionName = mention.name; } if (queuedMessagesToSend.length === 0) { // No queue - current input is primary primaryText = messageText; primaryAttachments = [...attachmentsToSend, ...mentionAttachments]; } else { // Has queue - current input is additional part additionalParts.push({ text: messageText, attachments: [...attachmentsToSend, ...mentionAttachments], }); } } const consumedDraftTarget = inlineDraftTarget; let drafts: InlineCommentDraft[] = []; if (!queuedOnly && consumedDraftTarget) { drafts = consumeDrafts(consumedDraftTarget); } if (drafts.length > 0) { if (queuedMessagesToSend.length === 0) { primaryText = appendInlineComments(primaryText, drafts); } else if (additionalParts.length > 0) { const lastPart = additionalParts[additionalParts.length - 1]; lastPart.text = appendInlineComments(lastPart.text, drafts); } else { primaryText = appendInlineComments(primaryText, drafts); } } // Add synthetic parts (from conflict resolution, etc.) if (syntheticParts && syntheticParts.length > 0) { for (const part of syntheticParts) { additionalParts.push({ text: part.text, synthetic: true, }); } } // Add linked issue as synthetic part (only the parts with synthetic: true) // The text part (synthetic: false) is completely dropped per requirements if (linkedIssue) { additionalParts.push({ text: linkedIssue.contextText, synthetic: true, }); } if (linkedPr) { additionalParts.push({ text: linkedPr.instructionsText, synthetic: true, }); additionalParts.push({ text: linkedPr.contextText, synthetic: true, }); } const skillMentionInstruction = buildSkillMentionInstruction(mentionedSkillNames); if (skillMentionInstruction) { additionalParts.push({ text: skillMentionInstruction, synthetic: true, }); } if (!primaryText && primaryAttachments.length === 0 && additionalParts.length === 0) return; // Clear queue and input if (messageQueueTarget && queuedMessageId) { removeFromQueue(messageQueueTarget, queuedMessageId); } else if (messageQueueTarget && hasQueuedMessages) { clearQueue(messageQueueTarget); } if (!queuedOnly) { setMessage(''); confirmedMentionsRef.current.clear(); // Clear per-session draft on submit persistDraftImmediately(chatDraftIdentity, ''); // Reset message history navigation state setHistoryIndex(-1); setDraftMessage(''); if (attachedFiles.length > 0) { clearAttachedFiles(); } // Close expanded input overlay when submitting setExpandedInput(false); } if (isMobile) { textareaRef.current?.blur(); } // Handle local slash commands only in normal mode const normalizedCommand = primaryText.trimStart(); if (inputMode === 'normal' && normalizedCommand.startsWith('/')) { const commandName = normalizedCommand .slice(1) .trim() .split(/\s+/)[0] ?.toLowerCase(); if (commandName === 'undo' && currentSessionId) { await useSessionUIStore.getState().handleSlashUndo(currentSessionId); scrollToBottom?.(); return; } else if (commandName === 'redo' && currentSessionId) { await useSessionUIStore.getState().handleSlashRedo(currentSessionId); scrollToBottom?.(); return; } else if (commandName === 'timeline' && currentSessionId) { setTimelineDialogOpen(true); return; } else if (commandName === 'compact' && currentSessionId) { try { await sessionActions.waitForConnectionOrThrow(); const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined; await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed')); } return; } else if (commandName === 'summary' && currentSessionId) { try { await sessionActions.waitForConnectionOrThrow(); // Everything after `/summary ` is an optional topic hint // the user wants the summary focused on. const topic = normalizedCommand.replace(/^\/summary\b/i, '').trim(); const topicLine = topic ? ` focused on: ${topic}` : ''; const topicBlock = topic ? `The user asked you to focus this summary on: ${topic}. Prioritize that topic; mention unrelated threads only in passing.` : ''; const visibleText = await renderMagicPrompt('session.summary.visible', { topic_line: topicLine }); const instructionsText = await renderMagicPrompt('session.summary.instructions', { topic_block: topicBlock }); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.summaryFailed')); } return; } else if (commandName === 'workspace-review' && (currentSessionId || newSessionDraftOpen)) { try { await sessionActions.waitForConnectionOrThrow(); const visibleText = await renderMagicPrompt('session.review.visible'); const instructionsText = await renderMagicPrompt('session.review.instructions'); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.reviewFailed')); } return; } else if (commandName === 'handoff-review' && currentSessionId && !isMobile && !isVSCodeRuntime()) { setReviewDialogOpen(true); return; } else if (commandName === 'plan-feature' && (currentSessionId || newSessionDraftOpen)) { try { await sessionActions.waitForConnectionOrThrow(); const visibleText = await renderMagicPrompt('session.plan.visible'); const instructionsText = await renderMagicPrompt('session.plan.instructions'); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.planFeatureFailed')); } return; } else if (commandName === 'craft-goal' && (currentSessionId || newSessionDraftOpen)) { try { await sessionActions.waitForConnectionOrThrow(); const idea = normalizedCommand.replace(/^\/craft-goal\b/i, '').trim(); const visibleText = await renderMagicPrompt('session.craftGoal.visible', { idea_block: idea ? `\n\nHere is my initial idea:\n${idea}` : '', }); const instructionsText = await renderMagicPrompt('session.craftGoal.instructions'); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.craftGoalFailed')); } return; } else if (commandName === 'catch-up' && (currentSessionId || newSessionDraftOpen)) { try { await sessionActions.waitForConnectionOrThrow(); const visibleText = await renderMagicPrompt('session.catchup.visible'); const instructionsText = await renderMagicPrompt('session.catchup.instructions'); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.catchUpFailed')); } return; } else if (commandName === 'debug' && (currentSessionId || newSessionDraftOpen)) { try { await sessionActions.waitForConnectionOrThrow(); const visibleText = await renderMagicPrompt('session.debug.visible'); const instructionsText = await renderMagicPrompt('session.debug.instructions'); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.debugFailed')); } return; } else if (commandName === 'weigh' && (currentSessionId || newSessionDraftOpen)) { try { await sessionActions.waitForConnectionOrThrow(); const visibleText = await renderMagicPrompt('session.weigh.visible'); const instructionsText = await renderMagicPrompt('session.weigh.instructions'); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.weighFailed')); } return; } else if (commandName === 'explore' && (currentSessionId || newSessionDraftOpen)) { try { await sessionActions.waitForConnectionOrThrow(); const visibleText = await renderMagicPrompt('session.explore.visible'); const instructionsText = await renderMagicPrompt('session.explore.instructions'); await sendMessage( visibleText, providerIdToSend, modelIdToSend, agentNameToSend, [], agentMentionName, [{ text: instructionsText, synthetic: true }], variantToSend, inputMode, sendMessageOptions, ); scrollToBottom?.(); } catch (error) { toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.exploreFailed')); } return; } } const currentSessionDirectory = currentSessionId ? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory : currentDirectory; const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false); if (shouldAddResponseStyle) { const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null); if (responseStyleInstruction) { additionalParts.push({ text: wrapSystemReminder(responseStyleInstruction), synthetic: true, }); } } try { const expandText = useSnippetsStore.getState().expandText; primaryText = await expandText(primaryText); for (const part of additionalParts) { if (!part.synthetic) part.text = await expandText(part.text); } } catch (error) { console.warn('[ChatInput] Failed to expand snippets, sending original text:', error); } // Collect all attachments for error recovery const allAttachments = [ ...primaryAttachments, ...additionalParts.flatMap(p => p.attachments ?? []), ]; const sendPromise = sendMessage( primaryText, providerIdToSend, modelIdToSend, agentNameToSend, primaryAttachments, agentMentionName, additionalParts.length > 0 ? additionalParts : undefined, variantToSend, inputMode, sendMessageOptions, ); const restoreConsumedDrafts = () => { if (consumedDraftTarget && drafts.length > 0) { useInlineCommentDraftStore.getState().restoreDrafts(consumedDraftTarget, drafts); } }; if (typeof window === 'undefined') { scrollToBottom?.(); } else { window.requestAnimationFrame(() => { scrollToBottom?.(); }); } void sendPromise.then(() => { // Clear linked issue after successful message send if (linkedIssue) { setLinkedIssue(null); } if (linkedPr) { setLinkedPr(null); } }).catch((error: unknown) => { const rawMessage = error instanceof Error ? error.message : typeof error === 'string' ? error : String(error ?? ''); const normalized = rawMessage.toLowerCase(); console.error('Message send failed:', rawMessage || error); restoreConsumedDrafts(); const currentInput = textareaRef.current?.value ?? messageRef.current; if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { setMessage(inputSnapshot.message); writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); } const isSoftNetworkError = normalized.includes('timeout') || normalized.includes('timed out') || normalized.includes('may still be processing') || normalized.includes('being processed') || normalized.includes('failed to fetch') || normalized.includes('networkerror') || normalized.includes('network error') || normalized.includes('gateway timeout') || normalized === 'failed to send message'; if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) { toast.error(t('chat.chatInput.toast.attachmentsTooLarge')); if (allAttachments.length > 0) { useInputStore.getState().setAttachedFiles(allAttachments); } return; } if (isSoftNetworkError) { if (allAttachments.length > 0) { useInputStore.getState().setAttachedFiles(allAttachments); toast.error(t('chat.chatInput.toast.sendAttachmentsFailed')); } return; } if (allAttachments.length > 0) { useInputStore.getState().setAttachedFiles(allAttachments); } toast.error(rawMessage || t('chat.chatInput.toast.messageSendFailed')); }); if (!isMobile) { textareaRef.current?.focus(); } }; // Update ref with latest handleSubmit on every render handleSubmitRef.current = handleSubmit; // Primary action for send/queue button — respects selected follow-up behavior const handlePrimaryAction = React.useCallback(() => { const inputSnapshot = getCurrentInputSnapshot(); const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); if (followUpBehavior === 'queue' && canQueue) { handleQueueMessage(); } else if (followUpBehavior === 'steer' && canQueue) { void handleSubmitRef.current({ delivery: 'steer' }); } else { void handleSubmitRef.current(); } }, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]); // Draft welcome presets: submit immediately. const submitPresetPrompt = React.useCallback((text: string) => { // The text goes straight into the submit (see SubmitOptions.presetText) // instead of through the composer input — the collapsed mobile pill has // no mounted textarea to stage it in. const draft = (textareaRef.current?.value ?? messageRef.current).trim(); const presetText = draft ? `${text}\n${draft}` : text; void handleSubmitRef.current({ presetText }); }, []); // Dictation: insert the transcript inline; optionally submit immediately. // getCurrentInputSnapshot reads textareaRef.current.value first, so setting // it synchronously lets handleSubmit pick up the text in the same tick. const handleDictationInsert = React.useCallback((text: string) => { setMessage((prev) => { const next = appendInlineText(prev, text); const textarea = textareaRef.current; if (textarea) { textarea.value = next; } return next; }); setTimeout(() => { textareaRef.current?.focus(); }, 0); }, []); const handleDictationInsertAndSend = React.useCallback((text: string) => { // Same as preset chips: the composed text goes into the submit as an // explicit override instead of being staged in the textarea, which may // not be mounted (collapsed mobile pill). const next = appendInlineText(textareaRef.current?.value ?? messageRef.current, text); void handleSubmitRef.current({ presetText: next }); }, []); // Preset chips rendered outside this component (e.g. under the welcome // message on narrow surfaces) request a submit via the input store; consume // it here so it routes through the same command-aware submit path. React.useEffect(() => { if (pendingPresetSubmit == null) return; const text = useInputStore.getState().consumePendingPresetSubmit(); if (text) submitPresetPrompt(text); }, [pendingPresetSubmit, submitPresetPrompt]); const handleKeyDown = (e: React.KeyboardEvent) => { // Early return during IME composition to prevent interference with autocomplete. // Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown. if (isIMECompositionEvent(e)) return; if (inputMode === 'shell' && e.key === 'Escape') { e.preventDefault(); setInputMode('normal'); return; } if (inputMode === 'shell' && e.key === 'Backspace' && message.length === 0) { e.preventDefault(); setInputMode('normal'); return; } if ((e.key === 'Backspace' || e.key === 'Delete') && !e.metaKey && !e.ctrlKey && !e.altKey) { const textarea = textareaRef.current; const selectionStart = textarea?.selectionStart ?? message.length; const selectionEnd = textarea?.selectionEnd ?? message.length; const hasCollapsedSelection = selectionStart === selectionEnd; if (hasCollapsedSelection) { const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart; if (probeIndex >= 0 && probeIndex < message.length) { let tokenStart = probeIndex; while (tokenStart > 0 && !/\s/.test(message[tokenStart - 1])) { tokenStart -= 1; } let tokenEnd = probeIndex + 1; while (tokenEnd < message.length && !/\s/.test(message[tokenEnd])) { tokenEnd += 1; } const token = message.slice(tokenStart, tokenEnd); const mentionContent = token.slice(1); const looksLikeFileMention = FILE_MENTION_TOKEN.test(token) && !knownAgentNamesRef.current.has(mentionContent.toLowerCase()) && isConfirmedFilePath(mentionContent); if (looksLikeFileMention) { confirmedMentionsRef.current.delete(mentionContent); const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd; const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`; e.preventDefault(); setMessage(nextMessage); requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = tokenStart; textareaRef.current.selectionEnd = tokenStart; } adjustTextareaHeight(); }); updateAutocompleteState(nextMessage, tokenStart); return; } } } } if (showCommandAutocomplete && commandRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); commandRef.current.handleKeyDown(e.key); return; } } if (showSkillAutocomplete && skillRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); skillRef.current.handleKeyDown(e.key); return; } } if (showSnippetAutocomplete && snippetRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); snippetRef.current.handleKeyDown(e.key); return; } } if (showFileMention && mentionRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); mentionRef.current.handleKeyDown(e.key); return; } } if (isDesktopExpanded && e.key === 'Escape') { e.preventDefault(); setExpandedInput(false); return; } const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift') ? normalizeCombo(`shift+${cycleAgentShortcut}`) : ''; const cycleAgentDirection = cycleAgentBackwardShortcut && eventMatchesShortcut(e, cycleAgentBackwardShortcut) ? -1 : eventMatchesShortcut(e, cycleAgentShortcut) ? 1 : 0; if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) { e.preventDefault(); e.stopPropagation(); handleCycleAgent(cycleAgentDirection); return; } // Handle ArrowUp/ArrowDown for message history navigation // ArrowUp: only when cursor at start (position 0) or input is empty // ArrowDown: also works when cursor at end (to cycle forward through history) const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showSnippetAutocomplete || showFileMention; const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0; const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length; const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart); const canNavigateHistoryDown = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtEnd); // Markdown-aware auto-pairing (source mode), normal input only. if (inputMode === 'normal' && !isAnyAutocompleteOpen && !e.metaKey && !e.ctrlKey && !e.altKey) { const ta = textareaRef.current; const selStart = ta?.selectionStart ?? -1; const selEnd = ta?.selectionEnd ?? -1; if (ta && selStart >= 0) { const applyEdit = (next: string, caretStart: number, caretEnd: number) => { e.preventDefault(); setMessage(next); requestAnimationFrame(() => { const current = textareaRef.current; if (current) { current.selectionStart = caretStart; current.selectionEnd = caretEnd; } adjustTextareaHeight(); }); updateAutocompleteState(next, caretEnd); }; // Wrap the current selection: select text, press ` * _ ~ ( [ { " ' const WRAP_PAIRS: Record = { '`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'], '(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'], '"': ['"', '"'], "'": ["'", "'"], }; if (selEnd > selStart && WRAP_PAIRS[e.key]) { const [open, close] = WRAP_PAIRS[e.key]; const selected = message.slice(selStart, selEnd); const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`; applyEdit(next, selStart + open.length, selEnd + open.length); return; } // Typing the third backtick at line start expands into a fenced // code block with the caret on the empty middle line (Slack-like). if (e.key === '`' && selStart === selEnd) { const before = message.slice(0, selStart); if (/(^|\n)``$/.test(before)) { const after = message.slice(selEnd); const next = `${before}\`\n\n\`\`\`${after}`; const caret = before.length + 2; // after the completed ``` and first newline applyEdit(next, caret, caret); return; } } } } if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) { e.preventDefault(); if (historyIndex === -1) { // Entering history mode - save current input as draft setDraftMessage(message); setHistoryIndex(0); setMessage(userMessageHistory[0]); } else if (historyIndex < userMessageHistory.length - 1) { // Navigate to older message const newIndex = historyIndex + 1; setHistoryIndex(newIndex); setMessage(userMessageHistory[newIndex]); } // Move cursor to start after history navigation requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(0, 0); }); // If at oldest message, do nothing return; } if (e.key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0) { e.preventDefault(); if (historyIndex === 0) { // Exit history mode - restore draft setHistoryIndex(-1); setMessage(draftMessage); setDraftMessage(''); } else { // Navigate to newer message const newIndex = historyIndex - 1; setHistoryIndex(newIndex); setMessage(userMessageHistory[newIndex]); } return; } // Handle Enter/Ctrl+Enter based on selected follow-up behavior. if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) { e.preventDefault(); const isCtrlEnter = e.ctrlKey || e.metaKey; // Queueing / steering only works when there's an existing busy // session (or an active auto-review run). const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning); if (followUpBehavior === 'queue') { if (isCtrlEnter || !canQueue) { handleSubmit(); } else { handleQueueMessage(); } } else { // steer: Enter steers into the running turn, Ctrl+Enter sends now. if (isCtrlEnter || !canQueue) { handleSubmit(); } else { handleSubmit({ delivery: 'steer' }); } } } }; const measureCaretInTextarea = React.useCallback((textarea: HTMLTextAreaElement, cursorPosition: number) => { const doc = textarea.ownerDocument; const win = doc.defaultView; if (!win) return null; const style = win.getComputedStyle(textarea); const mirror = doc.createElement('div'); const mirrorStyle = mirror.style; mirrorStyle.position = 'absolute'; mirrorStyle.visibility = 'hidden'; mirrorStyle.pointerEvents = 'none'; mirrorStyle.whiteSpace = 'pre-wrap'; mirrorStyle.wordWrap = 'break-word'; mirrorStyle.overflow = 'hidden'; mirrorStyle.left = '-9999px'; mirrorStyle.top = '0'; mirrorStyle.width = `${textarea.clientWidth}px`; mirrorStyle.font = style.font; mirrorStyle.fontSize = style.fontSize; mirrorStyle.fontFamily = style.fontFamily; mirrorStyle.fontWeight = style.fontWeight; mirrorStyle.fontStyle = style.fontStyle; mirrorStyle.fontVariant = style.fontVariant; mirrorStyle.letterSpacing = style.letterSpacing; mirrorStyle.textTransform = style.textTransform; mirrorStyle.textIndent = style.textIndent; mirrorStyle.padding = style.padding; mirrorStyle.border = style.border; mirrorStyle.boxSizing = style.boxSizing; mirrorStyle.lineHeight = style.lineHeight; mirrorStyle.tabSize = style.tabSize; mirror.textContent = textarea.value.slice(0, cursorPosition); const marker = doc.createElement('span'); marker.textContent = textarea.value.slice(cursorPosition, cursorPosition + 1) || ' '; mirror.appendChild(marker); doc.body.appendChild(mirror); const top = marker.offsetTop; const left = marker.offsetLeft; doc.body.removeChild(mirror); return { top, left }; }, []); const updateAutocompleteOverlayPosition = React.useCallback(() => { if (!isDesktopExpanded) { setAutocompleteOverlayPosition(null); return; } if (!showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) { setAutocompleteOverlayPosition(null); return; } const textarea = textareaRef.current; const container = dropZoneRef.current; if (!textarea || !container) return; const cursor = textarea.selectionStart ?? message.length; const caret = measureCaretInTextarea(textarea, cursor); if (!caret) return; const textareaRect = textarea.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); const caretY = textareaRect.top - containerRect.top + (caret.top - textarea.scrollTop); const caretX = textareaRect.left - containerRect.left + (caret.left - textarea.scrollLeft); const popupMargin = 8; const estimatedPopupHeight = 260; const spaceAbove = caretY - popupMargin; const spaceBelow = containerRect.height - caretY - popupMargin; const place: 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above'; const desiredWidth = showFileMention ? 520 : showCommandAutocomplete || showSnippetAutocomplete ? 450 : 360; const clampedLeft = Math.max( popupMargin, Math.min(caretX - 24, containerRect.width - desiredWidth - popupMargin) ); const maxHeight = Math.max(120, Math.min(estimatedPopupHeight, place === 'below' ? spaceBelow : spaceAbove)); setAutocompleteOverlayPosition({ top: place === 'below' ? caretY + 22 : caretY - 6, left: clampedLeft, place, maxHeight, }); }, [ isDesktopExpanded, measureCaretInTextarea, message.length, showCommandAutocomplete, showFileMention, showSnippetAutocomplete, showSkillAutocomplete, ]); React.useLayoutEffect(() => { updateAutocompleteOverlayPosition(); }, [ updateAutocompleteOverlayPosition, message, showCommandAutocomplete, showSkillAutocomplete, showSnippetAutocomplete, showFileMention, isDesktopExpanded, ]); React.useEffect(() => { if (!isDesktopExpanded) return; const onResize = () => updateAutocompleteOverlayPosition(); window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); }; }, [isDesktopExpanded, updateAutocompleteOverlayPosition]); const startAbortIndicator = React.useCallback(() => { if (abortTimeoutRef.current) { clearTimeout(abortTimeoutRef.current); abortTimeoutRef.current = null; } setShowAbortStatus(true); abortTimeoutRef.current = setTimeout(() => { setShowAbortStatus(false); abortTimeoutRef.current = null; }, 1800); }, []); const handleAbort = React.useCallback(() => { clearAbortPrompt(); startAbortIndicator(); void abortCurrentOperation(currentSessionId || undefined); }, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]); const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => { const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction); if (!nextAgentName) return; setAgent(nextAgentName); if (currentSessionId) { saveSessionAgentSelection(currentSessionId, nextAgentName); } }, [agents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); // Height the dictation transcript needs (null when idle): the overlay sits // absolutely over the composer, so the underlying textarea must grow for // the composer to grow — feed this into the autosize below. const dictationContentHeightRef = React.useRef(null); const [dictationContentHeight, setDictationContentHeight] = React.useState(null); const handleDictationContentHeightChange = React.useCallback((height: number | null) => { setDictationContentHeight((prev) => (prev === height ? prev : height)); }, []); const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => { const textarea = textareaRef.current; if (!textarea) { return; } const previousScrollTop = textarea.scrollTop; if (isComposerExpanded) { textarea.style.height = '100%'; textarea.style.maxHeight = 'none'; setTextareaSize(null); if (textarea.scrollTop !== previousScrollTop) { textarea.scrollTop = previousScrollTop; } return; } if (options?.allowShrink ?? true) { textarea.style.height = 'auto'; } const view = textarea.ownerDocument?.defaultView; const computedStyle = view ? view.getComputedStyle(textarea) : null; const lineHeight = computedStyle ? parseFloat(computedStyle.lineHeight) : NaN; const paddingTop = computedStyle ? parseFloat(computedStyle.paddingTop) : NaN; const paddingBottom = computedStyle ? parseFloat(computedStyle.paddingBottom) : NaN; const fallbackLineHeight = 22; const fallbackPadding = 16; const paddingTotal = Number.isNaN(paddingTop) || Number.isNaN(paddingBottom) ? fallbackPadding : paddingTop + paddingBottom; const targetLineHeight = Number.isNaN(lineHeight) ? fallbackLineHeight : lineHeight; const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal; const scrollHeight = textarea.scrollHeight || textarea.offsetHeight; const dictationHeight = dictationContentHeightRef.current ?? 0; const nextHeight = Math.min(Math.max(scrollHeight, dictationHeight), maxHeight); textarea.style.height = `${nextHeight}px`; textarea.style.maxHeight = `${maxHeight}px`; if (textarea.scrollTop !== previousScrollTop) { textarea.scrollTop = previousScrollTop; } setTextareaSize((prev) => { if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) { return prev; } return { height: nextHeight, maxHeight }; }); }, [isComposerExpanded]); React.useLayoutEffect(() => { const allowShrink = message.length < previousMessageLengthRef.current; previousMessageLengthRef.current = message.length; adjustTextareaHeight({ allowShrink }); }, [adjustTextareaHeight, message, isMobile]); React.useLayoutEffect(() => { dictationContentHeightRef.current = dictationContentHeight; // Growing transcript never shrinks mid-recording (matches typing); // dictation ending (null) releases the height back to the message. adjustTextareaHeight({ allowShrink: dictationContentHeight === null }); }, [adjustTextareaHeight, dictationContentHeight]); const updateAutocompleteState = React.useCallback(( value: string, cursorPosition: number, inputSource: FileMentionAutocompleteInputSource = 'manual', insertedText?: string, ) => { if (inputMode === 'shell') { setShowCommandAutocomplete(false); setShowFileMention(false); setShowSkillAutocomplete(false); setShowSnippetAutocomplete(false); return; } if (value.startsWith('/')) { const firstSpace = value.indexOf(' '); const firstNewline = value.indexOf('\n'); const commandEnd = Math.min( firstSpace === -1 ? value.length : firstSpace, firstNewline === -1 ? value.length : firstNewline ); if (cursorPosition <= commandEnd && firstSpace === -1) { const commandText = value.substring(1, commandEnd); setCommandQuery(commandText); setShowCommandAutocomplete(true); setShowFileMention(false); setShowSkillAutocomplete(false); setShowSnippetAutocomplete(false); return; } } setShowCommandAutocomplete(false); const textBeforeCursor = value.substring(0, cursorPosition); const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); if (lastSlashSymbol !== -1) { const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null; const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1); const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n'); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !hasSeparator) { setSkillQuery(textAfterSlash); setShowSkillAutocomplete(true); setShowFileMention(false); return; } } setShowSkillAutocomplete(false); setSkillQuery(''); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); if (lastHashSymbol !== -1) { const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null; const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1); const isWordBoundary = !charBefore || /\s/.test(charBefore); if (isWordBoundary && !textAfterHash.includes(' ') && !textAfterHash.includes('\n')) { setSnippetQuery(textAfterHash); setShowSnippetAutocomplete(true); setShowFileMention(false); return; } } setShowSnippetAutocomplete(false); const nextMentionQuery = getFileMentionAutocompleteQuery({ value, cursorPosition, inputSource, insertedText }); if (nextMentionQuery === null) { setShowFileMention(false); } else { setMentionQuery(nextMentionQuery); setShowFileMention(true); } }, [ inputMode, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setShowSnippetAutocomplete, setSkillQuery, setSnippetQuery, ]); const insertTextAtSelection = React.useCallback(( text: string, inputSource: FileMentionAutocompleteInputSource = 'manual', ) => { if (!text) { return; } const textarea = textareaRef.current; if (!textarea) { const nextValue = message + text; setMessage(nextValue); updateAutocompleteState(nextValue, nextValue.length, inputSource, text); requestAnimationFrame(() => adjustTextareaHeight()); return; } const start = textarea.selectionStart ?? message.length; const end = textarea.selectionEnd ?? message.length; const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`; setMessage(nextValue); const cursorPosition = start + text.length; requestAnimationFrame(() => { const currentTextarea = textareaRef.current; if (currentTextarea) { currentTextarea.selectionStart = cursorPosition; currentTextarea.selectionEnd = cursorPosition; } adjustTextareaHeight(); }); updateAutocompleteState(nextValue, cursorPosition, inputSource, text); }, [adjustTextareaHeight, message, updateAutocompleteState]); const clearDropTextSuppression = React.useCallback(() => { suppressNextFileDropTextInsertRef.current = false; pendingDroppedAbsolutePathsRef.current = []; if (suppressNextFileDropTextInsertTimeoutRef.current) { clearTimeout(suppressNextFileDropTextInsertTimeoutRef.current); suppressNextFileDropTextInsertTimeoutRef.current = null; } }, []); const scheduleDropTextSuppressionExpiry = React.useCallback(() => { if (suppressNextFileDropTextInsertTimeoutRef.current) { clearTimeout(suppressNextFileDropTextInsertTimeoutRef.current); } suppressNextFileDropTextInsertTimeoutRef.current = setTimeout(() => { clearDropTextSuppression(); }, 700); }, [clearDropTextSuppression]); const clearFileMentionPasteSuppression = React.useCallback(() => { suppressNextFileMentionPasteRef.current = false; if (suppressNextFileMentionPasteTimeoutRef.current) { clearTimeout(suppressNextFileMentionPasteTimeoutRef.current); suppressNextFileMentionPasteTimeoutRef.current = null; } }, []); const markFileMentionPasteSuppression = React.useCallback(() => { suppressNextFileMentionPasteRef.current = true; if (suppressNextFileMentionPasteTimeoutRef.current) { clearTimeout(suppressNextFileMentionPasteTimeoutRef.current); } suppressNextFileMentionPasteTimeoutRef.current = setTimeout(() => { suppressNextFileMentionPasteRef.current = false; suppressNextFileMentionPasteTimeoutRef.current = null; }, 700); }, []); const handleBeforeInput = React.useCallback((e: React.FormEvent) => { if (!isVSCodeRuntime() || !suppressNextFileDropTextInsertRef.current) { return; } const nativeInputEvent = e.nativeEvent as InputEvent | undefined; if (nativeInputEvent?.inputType === 'insertFromDrop') { e.preventDefault(); clearDropTextSuppression(); } }, [clearDropTextSuppression]); const handleTextChange = (e: React.ChangeEvent) => { const nativeInputEvent = e.nativeEvent as InputEvent | undefined; if (isVSCodeRuntime() && suppressNextFileDropTextInsertRef.current) { const candidateAbsolutePaths = pendingDroppedAbsolutePathsRef.current; const isLikelyDropTextInsertion = nativeInputEvent?.inputType === 'insertFromDrop' || candidateAbsolutePaths.some((path) => path.length > 0 && e.target.value.includes(path)); if (isLikelyDropTextInsertion) { clearDropTextSuppression(); return; } } const value = e.target.value; const cursorPosition = e.target.selectionStart ?? value.length; const pastedInsertedText = nativeInputEvent?.inputType?.startsWith('insertFromPaste') ? getInsertedTextFromChange(messageRef.current, value) : ''; const isPasteInput = pastedInsertedText.includes('@') || suppressNextFileMentionPasteRef.current; if (suppressNextFileMentionPasteRef.current) { clearFileMentionPasteSuppression(); } const inputSource: FileMentionAutocompleteInputSource = isPasteInput ? 'paste' : 'manual'; if (inputMode === 'normal' && value.startsWith('!')) { const shellCommand = value.slice(1); const nextCursor = Math.max(0, cursorPosition - 1); setInputMode('shell'); setMessage(shellCommand); adjustTextareaHeight(); setShowCommandAutocomplete(false); setShowSkillAutocomplete(false); setShowFileMention(false); requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } }); return; } setMessage(value); adjustTextareaHeight(); updateAutocompleteState(value, cursorPosition, inputSource, pastedInsertedText); }; React.useEffect(() => { return () => { clearDropTextSuppression(); clearFileMentionPasteSuppression(); }; }, [clearDropTextSuppression, clearFileMentionPasteSuppression]); const handlePaste = React.useCallback(async (e: React.ClipboardEvent) => { // Pasting a URL over a selection wraps it as a markdown link: // [selected text](pasted url). if (inputMode === 'normal' && (currentSessionId || newSessionDraftOpen)) { const ta = textareaRef.current; const selStart = ta?.selectionStart ?? -1; const selEnd = ta?.selectionEnd ?? -1; if (ta && selEnd > selStart) { const clipboardText = e.clipboardData.getData('text'); const url = clipboardText.trim(); const selected = message.slice(selStart, selEnd); if ( PASTE_LINK_URL_PATTERN.test(url) && !/\s/.test(url) && selected.trim().length > 0 && !selected.includes('](') ) { e.preventDefault(); const next = `${message.slice(0, selStart)}[${selected}](${url})${message.slice(selEnd)}`; const caret = selStart + 1 + selected.length + 2 + url.length + 1; setMessage(next); requestAnimationFrame(() => { const current = textareaRef.current; if (current) { current.selectionStart = caret; current.selectionEnd = caret; } adjustTextareaHeight(); }); updateAutocompleteState(next, caret, getFileMentionInputSourceForInsertedText(url), url); return; } } } const fileMap = new Map(); Array.from(e.clipboardData.files || []).forEach(file => { if (file.type.startsWith('image/')) { fileMap.set(`${file.name}-${file.size}`, file); } }); Array.from(e.clipboardData.items || []).forEach(item => { if (item.kind === 'file' && item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { fileMap.set(`${file.name}-${file.size}`, file); } } }); const imageFiles = Array.from(fileMap.values()); const pastedText = e.clipboardData.getData('text'); if (imageFiles.length === 0) { if (pastedText.includes('@')) { markFileMentionPasteSuppression(); } return; } if (!currentSessionId && !newSessionDraftOpen) { if (pastedText.includes('@')) { markFileMentionPasteSuppression(); } return; } e.preventDefault(); const assignedFilenames = assignImageAttachmentFilenames( imageFiles, [ ...attachedFiles.map((file) => file.filename), ...pendingPastedAttachmentFilenamesRef.current, ], ); const citationText = buildAttachmentCitationText(assignedFilenames); const textarea = textareaRef.current; const selectionStart = textarea?.selectionStart ?? message.length; const selectionEnd = textarea?.selectionEnd ?? message.length; const insertionText = withInlineInsertionBoundaries( buildImagePasteInsertion(pastedText, citationText), message.slice(0, selectionStart), message.slice(selectionEnd), ); insertTextAtSelection(insertionText, getFileMentionInputSourceForInsertedText(insertionText)); for (let index = 0; index < imageFiles.length; index += 1) { const filename = assignedFilenames[index]; const file = renameFileForAttachmentCitation(imageFiles[index], filename); pendingPastedAttachmentFilenamesRef.current.add(filename); try { await addAttachedFile(file); } catch (error) { console.error('Clipboard image attach failed', error); toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.clipboardAttachFailed')); } finally { pendingPastedAttachmentFilenamesRef.current.delete(filename); } } }, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => { const cursorPosition = textareaRef.current?.selectionStart || 0; const textBeforeCursor = message.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); const mentionPath = (file.relativePath && file.relativePath.trim().length > 0) ? file.relativePath.trim() : (toProjectRelativeMentionPath(file.path) || file.name); confirmedMentionsRef.current.add(mentionPath); if (lastAtSymbol !== -1) { const newMessage = message.substring(0, lastAtSymbol) + `@${mentionPath} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = lastAtSymbol + mentionPath.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } else if (textareaRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${mentionPath} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = cursorPosition + mentionPath.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } setShowFileMention(false); setMentionQuery(''); textareaRef.current?.focus(); }; const handleAgentSelect = (agentName: string) => { const textarea = textareaRef.current; const cursorPosition = textarea?.selectionStart ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); if (lastAtSymbol !== -1) { const newMessage = message.substring(0, lastAtSymbol) + `@${agentName} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = lastAtSymbol + agentName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } else if (textareaRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${agentName} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = cursorPosition + agentName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } setShowFileMention(false); setMentionQuery(''); textareaRef.current?.focus(); }; const handleSkillSelect = (skillName: string) => { const textarea = textareaRef.current; const cursorPosition = textarea?.selectionStart ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); if (lastSlashSymbol !== -1) { const newMessage = message.substring(0, lastSlashSymbol) + `/${skillName} ` + message.substring(cursorPosition); setMessage(newMessage); const nextCursor = lastSlashSymbol + skillName.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } setShowSkillAutocomplete(false); setSkillQuery(''); textareaRef.current?.focus(); }; const handleSnippetSelect = (_snippet: unknown, trigger: string) => { const textarea = textareaRef.current; const cursorPosition = textarea?.selectionStart ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition; const newMessage = `${message.substring(0, startIndex)}#${trigger} ${message.substring(cursorPosition)}`; setMessage(newMessage); const nextCursor = startIndex + trigger.length + 2; requestAnimationFrame(() => { if (textareaRef.current) { textareaRef.current.selectionStart = nextCursor; textareaRef.current.selectionEnd = nextCursor; } adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); setShowSnippetAutocomplete(false); setSnippetQuery(''); textareaRef.current?.focus(); }; const handleCommandSelect = (command: CommandInfo) => { setMessage(`/${command.name} `); const textareaElement = textareaRef.current as HTMLTextAreaElement & { _commandMetadata?: typeof command }; if (textareaElement) { textareaElement._commandMetadata = command; } setShowCommandAutocomplete(false); setCommandQuery(''); const refocus = () => { if (textareaRef.current) { try { textareaRef.current.focus({ preventScroll: true }); } catch { textareaRef.current.focus(); } textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length); } }; requestAnimationFrame(() => { refocus(); requestAnimationFrame(refocus); }); setTimeout(refocus, 60); }; React.useEffect(() => { if (currentSessionId && textareaRef.current && !isMobile) { textareaRef.current.focus(); } }, [currentSessionId, isMobile]); React.useEffect(() => { if (!isMobile) { setMobileControlsPanel(null); } }, [isMobile]); React.useEffect(() => { if (abortPromptSessionId && abortPromptSessionId !== currentSessionId) { clearAbortPrompt(); } }, [abortPromptSessionId, currentSessionId, clearAbortPrompt]); React.useEffect(() => { canAcceptDropRef.current = Boolean(currentSessionId || newSessionDraftOpen); }, [currentSessionId, newSessionDraftOpen]); const hasDraggedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): boolean => { if (!dataTransfer) return false; if (dataTransfer.files && dataTransfer.files.length > 0) return true; if (dataTransfer.types) { const types = Array.from(dataTransfer.types); const lowerTypes = types.map((type) => type.toLowerCase()); if (lowerTypes.includes('files')) return true; if (lowerTypes.includes('text/uri-list')) return true; if (lowerTypes.includes('codefiles')) return true; if (lowerTypes.includes('application/x-openchamber-file-path')) return true; if (lowerTypes.some((type) => type.includes('vnd.code.tree'))) return true; } for (const dataType of VS_CODE_DROP_DATA_TYPES) { let payload = ''; try { payload = dataTransfer.getData(dataType); } catch { continue; } if (payload && parseDroppedFileReferences(payload).length > 0) { return true; } } return false; }, []); const collectDroppedFiles = React.useCallback((dataTransfer: DataTransfer | null | undefined): File[] => { if (!dataTransfer) return []; const directFiles = Array.from(dataTransfer.files || []); if (directFiles.length > 0) { return directFiles; } const fromItems = Array.from(dataTransfer.items || []) .filter((item) => item.kind === 'file') .map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file)); return fromItems; }, []); const collectDroppedFileUris = React.useCallback((dataTransfer: DataTransfer | null | undefined): string[] => { if (!dataTransfer || typeof dataTransfer.getData !== 'function') return []; const extracted = new Set(); for (const dataType of VS_CODE_DROP_DATA_TYPES) { let rawPayload = ''; try { rawPayload = dataTransfer.getData(dataType); } catch { continue; } if (!rawPayload) { continue; } for (const candidate of parseDroppedFileReferences(rawPayload)) { extracted.add(candidate); } } return Array.from(extracted); }, []); const normalizeDroppedPath = React.useCallback((rawPath: string): string => { const input = rawPath.trim(); if (!input.toLowerCase().startsWith('file://')) { return input; } try { let pathname = decodeURIComponent(new URL(input).pathname || ''); if (/^\/[A-Za-z]:\//.test(pathname)) { pathname = pathname.slice(1); } return pathname || input; } catch { const stripped = input.replace(/^file:\/\//i, ''); try { return decodeURIComponent(stripped); } catch { return stripped; } } }, []); const toProjectRelativeMentionPath = React.useCallback((absolutePath: string): string => { const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim(); const normalizedRoot = (chatSearchDirectory || '').replace(/\\/g, '/').replace(/\/+$/, ''); if (!normalizedRoot) { return normalizedAbsolutePath; } if (normalizedAbsolutePath === normalizedRoot) { return normalizedAbsolutePath; } const rootWithSlash = `${normalizedRoot}/`; if (normalizedAbsolutePath.startsWith(rootWithSlash)) { return normalizedAbsolutePath.slice(rootWithSlash.length); } return normalizedAbsolutePath; }, [chatSearchDirectory]); const addVSCodeDroppedUrisAsMentions = React.useCallback((uris: string[]) => { if (uris.length === 0) return; const paths = uris .map((entry) => normalizeDroppedPath(entry)) .map((entry) => toProjectRelativeMentionPath(entry)) .map((entry) => entry.trim().replace(/^\.\//, '')) .filter((entry) => entry.length > 0); for (const p of paths) { confirmedMentionsRef.current.add(p); } const mentions = Array.from(new Set(paths.map((entry) => `@${entry}`))); if (mentions.length === 0) { return; } setPendingInputText(mentions.join(' '), 'append-inline'); toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length })); }, [normalizeDroppedPath, setPendingInputText, t, toProjectRelativeMentionPath]); const handleDragEnter = (e: React.DragEvent) => { if (!hasDraggedFiles(e.dataTransfer)) { return; } e.preventDefault(); e.stopPropagation(); dragEnterCountRef.current++; const isInternal = e.dataTransfer.types?.includes('application/x-openchamber-file-path') ?? false; if (isInternal !== isInternalDrag) { setIsInternalDrag(isInternal); } if ((currentSessionId || newSessionDraftOpen) && !isDragging) { setIsDragging(true); } }; const handleDragOver = (e: React.DragEvent) => { if (!hasDraggedFiles(e.dataTransfer)) { return; } e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; if ((currentSessionId || newSessionDraftOpen) && !isDragging) { setIsDragging(true); } }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragEnterCountRef.current--; if (dragEnterCountRef.current <= 0) { dragEnterCountRef.current = 0; setIsDragging(false); setIsInternalDrag(false); clearDropTextSuppression(); } }; const handleDragEnd = () => { dragEnterCountRef.current = 0; setIsDragging(false); setIsInternalDrag(false); clearDropTextSuppression(); }; const handleDrop = async (e: React.DragEvent) => { dragEnterCountRef.current = 0; const draggedFiles = hasDraggedFiles(e.dataTransfer); if (!draggedFiles) { clearDropTextSuppression(); return; } e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (!currentSessionId && !newSessionDraftOpen) return; // Internal drag: file tree → chat input (relative path as @mention) const internalPath = e.dataTransfer.getData('application/x-openchamber-file-path'); if (internalPath && internalPath !== '.') { confirmedMentionsRef.current.add(internalPath); const mention = `@${internalPath}`; const textarea = textareaRef.current; const currentMessage = messageRef.current; if (textarea) { const pos = textarea.selectionStart ?? cursorPosRef.current; const end = textarea.selectionEnd ?? pos; const before = currentMessage.slice(0, pos); const after = currentMessage.slice(end); const needSpaceBefore = before.length > 0 && !/\s$/.test(before); const needSpaceAfter = after.length > 0 && !/^\s/.test(after); const insert = `${needSpaceBefore ? ' ' : ''}${mention}${needSpaceAfter ? ' ' : ''}`; const nextMessage = `${before}${insert}${after}`; setMessage(nextMessage); requestAnimationFrame(() => { const cursorPos = pos + insert.length; textarea.selectionStart = cursorPos; textarea.selectionEnd = cursorPos; cursorPosRef.current = cursorPos; textarea.focus(); }); } else { setMessage((prev) => appendInlineText(prev, mention)); } clearDropTextSuppression(); return; } const files = collectDroppedFiles(e.dataTransfer); if (files.length === 0 && isVSCodeRuntime()) { const droppedUris = collectDroppedFileUris(e.dataTransfer); if (droppedUris.length > 0) { pendingDroppedAbsolutePathsRef.current = droppedUris .map((entry) => normalizeDroppedPath(entry)) .map((entry) => entry.trim()) .filter((entry) => entry.length > 0); addVSCodeDroppedUrisAsMentions(droppedUris); } else { clearDropTextSuppression(); } return; } if (files.length > 0) { let attached = false; for (const file of files) { try { attached = (await addAttachedFile(file)) || attached; } catch (error) { console.error('File attach failed', error); } } if (!attached) toast.error(t('chat.chatInput.toast.attachFileFailed')); } clearDropTextSuppression(); }; const handleDropCapture = (e: React.DragEvent) => { if (!hasDraggedFiles(e.dataTransfer)) { return; } // Prevent native textarea drop text insertion for all runtimes e.preventDefault(); if (isVSCodeRuntime()) { suppressNextFileDropTextInsertRef.current = true; scheduleDropTextSuppressionExpiry(); } }; const fileInputRef = React.useRef(null); const attachFiles = React.useCallback(async (files: FileList | File[]) => { const list = Array.isArray(files) ? files : Array.from(files); let attached = false; for (const file of list) { try { attached = (await addAttachedFile(file)) || attached; } catch (error) { console.error('File attach failed', error); } } if (list.length > 0 && !attached) { toast.error(t('chat.chatInput.toast.attachFileFailed')); } }, [addAttachedFile, t]); const handleVSCodePickFiles = React.useCallback(async () => { try { const data = (await vscodeApi?.pickFiles?.({ extensions: ACCEPTED_ATTACHMENT_EXTENSIONS })) as { files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>; skipped?: Array<{ name?: string; reason?: string }>; } | undefined; const picked = Array.isArray(data?.files) ? data.files : []; const skipped = Array.isArray(data?.skipped) ? data.skipped : []; if (skipped.length > 0) { const summary = skipped .map((s: { name?: string; reason?: string }) => `${s?.name || 'file'}: ${s?.reason || 'skipped'}`) .join('\n'); toast.error(t('chat.chatInput.toast.someFilesSkipped', { summary })); } const asFiles = picked .map((file: { name: string; mimeType?: string; dataUrl?: string }) => { if (!file?.dataUrl) return null; try { const [meta, base64] = file.dataUrl.split(','); const mime = file.mimeType || (meta?.match(/data:(.*);base64/)?.[1] || 'application/octet-stream'); if (!base64) return null; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: mime }); return new File([blob], file.name || 'file', { type: mime }); } catch (err) { console.error('Failed to decode VS Code picked file', err); return null; } }) .filter(Boolean) as File[]; if (asFiles.length > 0) { await attachFiles(asFiles); } } catch (error) { console.error('VS Code file pick failed', error); toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed')); } }, [attachFiles, t, vscodeApi]); const handlePickLocalFiles = React.useCallback(() => { if (isVSCodeRuntime()) { void handleVSCodePickFiles(); return; } fileInputRef.current?.click(); }, [handleVSCodePickFiles]); const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent) => { const files = event.target.files; if (!files) return; await attachFiles(files); event.target.value = ''; }, [attachFiles]); const footerGapClass = 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode; const selectedDraftProject = React.useMemo(() => { const explicit = newSessionDraft?.selectedProjectId ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null : null; if (explicit) { return explicit; } const active = activeProjectId ? projects.find((project) => project.id === activeProjectId) ?? null : null; if (active) { return active; } return projects[0] ?? null; }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); const selectedDraftProjectPath = React.useMemo( () => normalizePath(selectedDraftProject?.path ?? null), [selectedDraftProject?.path], ); const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null; const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath); const selectedDraftProjectBranchesFetchedAt = useGitStore( (s) => (selectedDraftProjectPath ? s.directories.get(selectedDraftProjectPath)?.lastBranchesFetch ?? 0 : 0), ); const selectedDraftProjectIsGitRepo = useIsGitRepo(selectedDraftProjectPath); const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all); const fetchBranches = useGitStore((state) => state.fetchBranches); const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false); React.useEffect(() => { if (!showDraftTargetSelectors || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) { return; } void fetchGitStatus(selectedDraftProjectPath, runtimeGit, { silent: true }); }, [fetchGitStatus, runtimeGit, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]); React.useEffect(() => { if (!showDraftTargetSelectors || !selectedDraftProjectPath || !selectedDraftProject || !runtimeGit || selectedDraftProjectIsGitRepo !== true) { setIsDiscoveringDraftBranches(false); return; } // Stale-while-revalidate: branches seeded from the persisted cache show // instantly. Refresh based on staleness (not mere presence) so a cached // list can't go stale, while only showing the discovering spinner when // there is nothing to display yet. const DRAFT_BRANCHES_SWR_TTL_MS = 30_000; const isStale = !selectedDraftProjectBranchesFetchedAt || Date.now() - selectedDraftProjectBranchesFetchedAt > DRAFT_BRANCHES_SWR_TTL_MS; if (hasDraftBranchList && !isStale) { setIsDiscoveringDraftBranches(false); return; } let cancelled = false; setIsDiscoveringDraftBranches(!hasDraftBranchList); void fetchBranches(selectedDraftProjectPath, runtimeGit) .finally(() => { if (!cancelled) { setIsDiscoveringDraftBranches(false); } }); return () => { cancelled = true; }; }, [fetchBranches, runtimeGit, selectedDraftProject, selectedDraftProjectBranchesFetchedAt, hasDraftBranchList, selectedDraftProjectIsGitRepo, selectedDraftProjectPath, showDraftTargetSelectors]); const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches?.current?.trim() ?? ''; const projectRootBranchOption = React.useMemo(() => { if (!selectedDraftProject) { return null; } const value = normalizePath(selectedDraftProject.path); if (!value) { return null; } if (!selectedDraftProjectCurrentBranch) { return null; } return { value, label: selectedDraftProjectCurrentBranch, }; }, [selectedDraftProject, selectedDraftProjectCurrentBranch]); const worktreeBranchOptions = React.useMemo(() => { if (!selectedDraftProject) { return []; } const worktrees = (() => { if (!selectedDraftProjectPath) { return []; } return availableWorktreesByProject.get(selectedDraftProjectPath) ?? availableWorktreesByProject.get(selectedDraftProject.path) ?? []; })(); return buildSessionTargetOptions({ projectRoot: normalizePath(selectedDraftProject.path) ?? '', rootBranch: selectedDraftProjectCurrentBranch, worktrees, pendingBootstrapDirectory: newSessionDraft?.bootstrapPendingDirectory ?? null, }).filter((option) => option.kind === 'worktree'); }, [availableWorktreesByProject, newSessionDraft?.bootstrapPendingDirectory, selectedDraftProject, selectedDraftProjectCurrentBranch, selectedDraftProjectPath]); const selectedDraftDirectory = React.useMemo( () => normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null) ?? normalizePath(newSessionDraft?.directoryOverride ?? null) ?? selectedDraftProjectPath, [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath], ); const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => { const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); return Boolean( newSessionDraft?.preserveDirectoryOverride || newSessionDraft?.pendingWorktreeRequestId || (pendingDirectory && pendingDirectory === selectedDraftDirectory) ); }, [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory]); const draftBranchItems = React.useMemo(() => { const baseItems: Array<{ value: string; label: string }> = []; if (projectRootBranchOption) { baseItems.push(projectRootBranchOption); } baseItems.push(...worktreeBranchOptions); if (!selectedDraftDirectory) { return baseItems; } if (baseItems.some((option) => option.value === selectedDraftDirectory)) { return baseItems; } if (!shouldKeepMissingSelectedDraftDirectory) { return baseItems; } return [ ...baseItems, { value: selectedDraftDirectory, label: formatDirectoryName(selectedDraftDirectory) }, ]; }, [projectRootBranchOption, selectedDraftDirectory, shouldKeepMissingSelectedDraftDirectory, worktreeBranchOptions]); const selectedDraftBranchLabel = React.useMemo(() => { const selectedValue = selectedDraftDirectory ?? draftBranchItems[0]?.value ?? null; if (!selectedValue) { return null; } return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue); }, [draftBranchItems, selectedDraftDirectory]); const chatSurfaceMode = useChatSurfaceMode(); const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; const hasPendingChanges = React.useMemo(() => { if (isMiniChatSurface) { return false; } if (isGitRepo !== true || !currentGitStatus || currentGitStatus.isClean) { return false; } return extractGitChangedFiles(currentGitStatus.files, currentGitStatus.diffStats, currentDirectory).length > 0; }, [currentDirectory, currentGitStatus, isGitRepo, isMiniChatSurface]); const selectedDraftBranchIsKnown = React.useMemo(() => { if (!selectedDraftDirectory) { return true; } if (projectRootBranchOption?.value === selectedDraftDirectory) { return true; } return worktreeBranchOptions.some((option) => option.value === selectedDraftDirectory); }, [projectRootBranchOption?.value, selectedDraftDirectory, worktreeBranchOptions]); React.useEffect(() => { if (!newSessionDraft?.open || !newSessionDraft?.preserveDirectoryOverride) { return; } if (!selectedDraftDirectory || !selectedDraftBranchIsKnown) { return; } useSessionUIStore.getState().setDraftPreserveDirectoryOverride(false); }, [newSessionDraft?.open, newSessionDraft?.preserveDirectoryOverride, selectedDraftBranchIsKnown, selectedDraftDirectory]); const shouldShowDraftBranchSelector = React.useMemo(() => { if (selectedDraftProjectIsGitRepo !== true) { return false; } if (isDiscoveringDraftBranches) { return false; } if (projectRootBranchOption) { return true; } return worktreeBranchOptions.length > 0; }, [isDiscoveringDraftBranches, projectRootBranchOption, selectedDraftProjectIsGitRepo, worktreeBranchOptions.length]); const handleDraftProjectChange = React.useCallback((projectId: string) => { const draft = useSessionUIStore.getState().newSessionDraft; if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { return; } const project = projects.find((entry) => entry.id === projectId); if (!project) { return; } if (activeProjectId !== projectId) { setActiveProjectIdOnly(projectId); } setNewSessionDraftTarget({ projectId, directoryOverride: project.path, }, { force: true }); }, [activeProjectId, projects, setActiveProjectIdOnly, setNewSessionDraftTarget]); const handleDraftDirectoryChange = React.useCallback((directory: string) => { const draft = useSessionUIStore.getState().newSessionDraft; if (draft?.pendingWorktreeRequestId || draft?.bootstrapPendingDirectory || draft?.preserveDirectoryOverride) { return; } if (!selectedDraftProject) { return; } setNewSessionDraftTarget({ projectId: selectedDraftProject.id, directoryOverride: directory, }, { force: true }); }, [selectedDraftProject, setNewSessionDraftTarget]); const renderProjectLabelWithIcon = React.useCallback((project: { id: string; path: string; label?: string; icon?: string | null; color?: string | null; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; iconBackground?: string | null; }) => { const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = getProjectIconColor(project.color); const fallbackIcon = projectIconName ? ( ) : ( ); return ( {project.iconImage ? ( ) : fallbackIcon} {getProjectDisplayLabel(project)} ); }, [currentTheme.colors.surface.foreground, currentTheme.metadata.variant]); React.useEffect(() => { if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) { return; } if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) { return; } const valid = draftBranchItems.some((option) => option.value === selectedDraftDirectory); if (valid) { return; } setNewSessionDraftTarget({ projectId: selectedDraftProject.id, directoryOverride: selectedDraftProject.path, }); }, [draftBranchItems, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]); // ── Mobile pill composer state machine ───────────────────────────────── const expandMobileComposer = React.useCallback((intent: 'focus') => { mobileExpandIntentRef.current = intent; // flushSync so the textarea exists NOW and focus() still runs inside // the user gesture's call stack: mobile browsers only open the soft // keyboard for focus calls made synchronously from the tap (an rAF // here worked in the Capacitor WebView but not in Safari/Chrome). flushSync(() => { setMobileComposerExpanded(true); }); // Capacitor: our keyboard choreography positions everything, so the // browser's own scroll-into-view must stay off. Mobile BROWSERS have no // choreography — the native reveal (viewport pan that lifts the focused // field above the keyboard) is the only thing that moves the composer. textareaRef.current?.focus({ preventScroll: isCapacitorApp() }); }, []); const applyAssistSuggestion = React.useCallback((text: string) => { setMessage(text); if (isMobile && !mobileComposerExpanded) { expandMobileComposer('focus'); } else { requestAnimationFrame(() => textareaRef.current?.focus()); } }, [expandMobileComposer, isMobile, mobileComposerExpanded]); const handleMobileNewSession = React.useCallback(() => { if (newSessionDraftOpen) return; openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined); }, [newSessionDraftOpen, openNewSessionDraft, currentDirectory]); const openMobileAttachSheet = React.useCallback(() => { // Same order as handleOpenMobilePanel: mark the sheet open BEFORE the // blur so the collapse watcher sees an overlay when the keyboard-close // lands. The trigger button blocks the tap's own focus transfer, so // the keyboard must be dismissed explicitly here. setMobileAttachMenuOpen(true); textareaRef.current?.blur(); }, []); const mobileComposerExpandedRef = React.useRef(mobileComposerExpanded); React.useEffect(() => { mobileComposerExpandedRef.current = mobileComposerExpanded; }); const handleMobileDictationActiveChange = React.useCallback((active: boolean) => { setMobileDictationActive(active); if (active) { mobileExpandIntentRef.current = null; // Dictation engine went live (possibly started from the pill): // switch straight into the voice variant of the full composer. if (!mobileComposerExpandedRef.current) { setMobileComposerExpanded(true); } return; } // Dictation ended. The insert flow hands focus back to the textarea a // tick later — if that happened, stay expanded; otherwise (cancel, // discard, insert-and-send) collapse straight back to the pill without // parking on the normal composer for the usual grace period. window.setTimeout(() => { if (!mobileComposerExpandedRef.current) return; if (document.activeElement === textareaRef.current) return; setMobileComposerExpanded(false); setExpandedInput(false); }, 30); }, [setExpandedInput]); // Watch the shared overlay portal root: any mounted MobileOverlayPanel // (sessions sheet, model/agent panels, draft pickers, ...) counts as busy. // Observing the host catches overlays whose open-state lives in other // components without threading their state here. React.useEffect(() => { if (!isMobile || typeof document === 'undefined') return; let host = document.getElementById('mobile-overlay-root'); if (!host) { // Same lazy-create contract as MobileOverlayPanel's ensureOverlayRoot. host = document.createElement('div'); host.id = 'mobile-overlay-root'; document.body.appendChild(host); } const hostEl = host; const update = () => setMobileOverlayHostBusy(hostEl.childElementCount > 0); update(); const observer = new MutationObserver(update); observer.observe(hostEl, { childList: true }); return () => observer.disconnect(); }, [isMobile]); // If the keyboard was open (or closed just moments ago by the overlay's own // blur) when an overlay appeared, bring it back once every overlay is gone. // The attach dropdown and the GitHub issue/PR pickers join the same chain, // so menu → picker → close restores the keyboard at the end of the flow. const mobileOverlayOpen = mobileOverlayHostBusy || Boolean(mobileControlsPanel) || mobileAttachMenuOpen || issuePickerOpen || prPickerOpen; // Installed PWA (standalone): a focus() from a bare timeout is outside the // user gesture and iOS refuses to raise the keyboard for it (Safari // in-browser is lenient). MobileOverlayPanel dispatches // 'oc:mobile-overlay-closed' synchronously from the same React flush as // the click that closed it — refocus right there, while the gesture is // still live. Chained flows (attach menu → GitHub picker) set the skip ref // so the keyboard doesn't flash open under the next overlay. const mobilePickerDialogsOpenRef = React.useRef(false); mobilePickerDialogsOpenRef.current = issuePickerOpen || prPickerOpen; const skipNextOverlayCloseRestoreRef = React.useRef(false); const openSheetCountRef = React.useRef(0); const holdComposerFocusUntilRef = React.useRef(0); React.useEffect(() => { if (!isMobile || isCapacitorApp() || typeof window === 'undefined') return; if (!window.matchMedia?.('(display-mode: standalone)')?.matches) return; const handleOverlayOpened = () => { openSheetCountRef.current += 1; }; const handleOverlayClosed = () => { // Counter instead of a DOM check: the close event fires from a // layout-effect cleanup, when the closing sheet's portal nodes may // still be attached — the DOM can't tell "this sheet going away" // from "another sheet still up". openSheetCountRef.current = Math.max(0, openSheetCountRef.current - 1); if (skipNextOverlayCloseRestoreRef.current) { skipNextOverlayCloseRestoreRef.current = false; return; } if (!restoreKeyboardAfterOverlayRef.current) return; if (mobilePickerDialogsOpenRef.current) return; if (openSheetCountRef.current > 0) return; restoreKeyboardAfterOverlayRef.current = false; // iOS can still dismiss the freshly-raised keyboard when the tap // that closed the overlay finishes over non-input content — hold // focus through that window (see the onBlur guard). holdComposerFocusUntilRef.current = Date.now() + 600; textareaRef.current?.focus(); // The native focus lands mid-commit; React's delegated onFocus may // not make it into this flush, leaving mobileComposerBusy false for // a beat — enough for the pill-collapse timer to unmount the // focused textarea and kill the rising keyboard. Set the state // explicitly instead of relying on the synthetic event. if (document.activeElement === textareaRef.current) { setMobileTextareaFocused(true); } // iOS reveals a field above the keyboard only for user-initiated // focus; a programmatic one leaves the composer parked behind it // (the chat screen has no viewport pin of its own — the draft // screen's pinned form ignores these no-op scrolls). Reveal once // the keyboard has mostly risen, and again after it settles. const reveal = () => { const ta = textareaRef.current; if (!ta || document.activeElement !== ta) return; // Align the BOTTOM of the whole composer form with the visible // bottom: 'nearest' on the textarea alone leaves the footer // icon row parked behind the keyboard accessory bar. (composerFormRef.current ?? ta).scrollIntoView({ block: 'end' }); }; window.setTimeout(reveal, 300); window.setTimeout(reveal, 650); }; window.addEventListener('oc:mobile-overlay-opened', handleOverlayOpened); window.addEventListener('oc:mobile-overlay-closed', handleOverlayClosed); return () => { window.removeEventListener('oc:mobile-overlay-opened', handleOverlayOpened); window.removeEventListener('oc:mobile-overlay-closed', handleOverlayClosed); }; }, [isMobile]); React.useEffect(() => { if (!isMobile) return; if (mobileOverlayOpen) { if (mobileTextareaFocused || Date.now() - lastMobileBlurAtRef.current < 800) { restoreKeyboardAfterOverlayRef.current = true; } return; } if (!restoreKeyboardAfterOverlayRef.current) return; // Debounced: overlay chains hand off with a frame of "nothing open" // between steps (attach sheet closes → issue/PR picker opens a frame // later). Restoring instantly in that gap would pop the keyboard open // inside the next overlay — wait out the gap and cancel if another // overlay appears. const timer = window.setTimeout(() => { restoreKeyboardAfterOverlayRef.current = false; // Browsers need their native scroll-into-view (see expandMobileComposer). textareaRef.current?.focus({ preventScroll: isCapacitorApp() }); }, 180); return () => window.clearTimeout(timer); }, [isMobile, mobileOverlayOpen, mobileTextareaFocused]); // Fold the full composer back into the pill once nothing keeps it open: // keyboard closed (textarea blurred), no dictation, no sheet/menu/dialog. // The short delay bridges focus moving between composer controls. const mobileComposerBusy = mobileTextareaFocused || mobileOverlayHostBusy || mobileDictationActive || Boolean(mobileControlsPanel) || mobileAttachMenuOpen || mobileDraftPicker !== null || issuePickerOpen || prPickerOpen || isDragging; React.useEffect(() => { if (!isMobile || !mobileComposerExpanded || mobileComposerBusy) return; const timer = window.setTimeout(() => { // Authoritative DOM check: the React focus state can lag a // programmatic refocus (overlay-close keyboard restore). Collapsing // would unmount the focused textarea and kill the keyboard. if (document.activeElement === textareaRef.current) return; mobileExpandIntentRef.current = null; setMobileComposerExpanded(false); setExpandedInput(false); }, 250); return () => window.clearTimeout(timer); }, [isMobile, mobileComposerExpanded, mobileComposerBusy, setExpandedInput]); const mobileComposerBusyRef = React.useRef(false); mobileComposerBusyRef.current = mobileComposerBusy; // Browser counterpart of Capacitor's oc-keyboard-open root class (which is // driven by native keyboard events): the focused composer textarea is the // best keyboard proxy a browser has. CSS keyed on it hides the draft // starters while typing, mirroring the native app. React.useEffect(() => { if (!isMobile || isCapacitorApp() || typeof document === 'undefined') return; const root = document.documentElement; if (mobileTextareaFocused) { root.classList.add('oc-browser-keyboard-open'); } else { root.classList.remove('oc-browser-keyboard-open'); // Installed PWA (standalone): after the keyboard dismisses, WebKit // can leave the layout viewport stuck smaller / panned (content // shifted up with a dead strip at the bottom) until something // forces it to recompute. A zero scroll after the keyboard's exit // animation settles snaps it back; harmless when nothing is stuck. if (window.matchMedia?.('(display-mode: standalone)')?.matches) { window.setTimeout(() => { if (root.classList.contains('oc-browser-keyboard-open')) return; window.scrollTo(0, 0); document.body.scrollTop = 0; root.scrollTop = 0; }, 350); } } return () => root.classList.remove('oc-browser-keyboard-open'); }, [isMobile, mobileTextareaFocused]); // Capacitor: collapse in the SAME frame the keyboard starts hiding. The // hide choreography dispatches oc:keyboard-intent BEFORE restoring the // shell layout and measuring the chat compensation; flushSync commits the // pill swap first, so keyboard land + composer shrink are measured (and // compensated) as ONE motion instead of a two-step staircase. The delayed // effect above stays as the fallback for non-Capacitor and for overlays // closing without a keyboard transition. React.useEffect(() => { if (!isMobile || typeof window === 'undefined') return; const handleIntent = (event: Event) => { const detail = (event as CustomEvent<{ open?: boolean }>).detail; if (!detail || detail.open !== false) return; if (!mobileComposerExpandedRef.current) return; // Something still holds the composer open (dictation, an overlay // that closed the keyboard, drag) — the fallback path handles it. if (mobileComposerBusyRef.current) return; mobileExpandIntentRef.current = null; flushSync(() => { setMobileComposerExpanded(false); setExpandedInput(false); }); }; window.addEventListener('oc:keyboard-intent', handleIntent); return () => window.removeEventListener('oc:keyboard-intent', handleIntent); }, [isMobile, setExpandedInput]); // Reset the picker search whenever a draft picker sheet opens/closes. React.useEffect(() => { setMobileDraftPickerQuery(''); }, [mobileDraftPicker]); // ── Composer drag handle (mobile): swipe up = fullscreen, swipe down = // leave fullscreen or dismiss the keyboard. ──────────────────────────── const handleComposerHandleTouchStart = React.useCallback((event: React.TouchEvent) => { const touch = event.touches.item(0); composerHandleTouchRef.current = touch ? { startY: touch.clientY, fired: false } : null; }, []); const handleComposerHandleTouchMove = React.useCallback((event: React.TouchEvent) => { const state = composerHandleTouchRef.current; if (!state || state.fired) return; const touch = event.touches.item(0); if (!touch) return; const dy = touch.clientY - state.startY; if (dy <= -28) { state.fired = true; if (!isExpandedInput) setExpandedInput(true); } else if (dy >= 28) { state.fired = true; if (isExpandedInput) { setExpandedInput(false); } else { textareaRef.current?.blur(); } } }, [isExpandedInput, setExpandedInput]); const handleComposerHandleTouchEnd = React.useCallback(() => { composerHandleTouchRef.current = null; }, []); // Fullscreen composer in a mobile BROWSER: the page layout doesn't shrink // for the keyboard there — Safari pans/scrolls instead, so any flow-based // sizing ends up partly off-screen or under the keyboard (the chat page is // usually already panned when fullscreen is entered). Pin the form to the // VISUAL viewport directly: fixed at its offset with its height, updated // as the browser pans. Capacitor is excluded — its shell already resizes // via the keyboard choreography. const composerFormRef = React.useRef(null); React.useLayoutEffect(() => { if (!isMobile || !isMobileExpanded || isCapacitorApp()) return; const vv = window.visualViewport; const form = composerFormRef.current; const textarea = textareaRef.current; if (!vv || !form) return; // The form is trapped inside lower stacking contexts (the composer // wrapper's z-10), so it cannot out-stack the app header with z-index // alone — hide the header for the duration via a root class instead. document.documentElement.classList.add('oc-browser-kb-fullscreen'); const apply = () => { const top = Math.max(0, Math.floor(vv.offsetTop)); // Same stale-visualViewport guard as the draft pin below: when the // layout viewport is keyboard-resized (interactive-widget), its // clientHeight is the authoritative above-keyboard height. const layoutHeight = document.documentElement.clientHeight; form.style.position = 'fixed'; form.style.left = '0'; form.style.right = '0'; form.style.top = `${top}px`; form.style.height = `${Math.floor(Math.min(vv.height, layoutHeight - top))}px`; form.style.zIndex = '40'; form.style.background = 'var(--background)'; }; apply(); vv.addEventListener('resize', apply); vv.addEventListener('scroll', apply); window.addEventListener('resize', apply); window.addEventListener('scroll', apply, true); return () => { vv.removeEventListener('resize', apply); vv.removeEventListener('scroll', apply); window.removeEventListener('resize', apply); window.removeEventListener('scroll', apply, true); document.documentElement.classList.remove('oc-browser-kb-fullscreen'); form.style.position = ''; form.style.left = ''; form.style.right = ''; form.style.top = ''; form.style.height = ''; form.style.zIndex = ''; form.style.background = ''; // Back in flow: the browser panned/scrolled for the fullscreen // session and won't re-reveal the (still focused) field on its own, // which left the composer parked behind the keyboard. requestAnimationFrame(() => { if (textarea && document.activeElement === textarea) { textarea.scrollIntoView({ block: 'nearest' }); } }); }; }, [isMobile, isMobileExpanded]); // Draft screen in a mobile BROWSER with the keyboard open: Safari's own // focused-field reveal is unreliable there (leaving the composer behind // the keyboard, e.g. after collapsing from fullscreen), so the NORMAL // composer is pinned to the visual viewport too — anchored to its visible // bottom at its natural height. The chat screen doesn't need this (its // reveal works) and Capacitor has the keyboard choreography. React.useLayoutEffect(() => { if (!isMobile || isCapacitorApp()) return; if (!newSessionDraftOpen || isMobileExpanded || !mobileTextareaFocused) return; const vv = window.visualViewport; const form = composerFormRef.current; if (!vv || !form) return; // Keep the in-flow horizontal geometry (page paddings) while fixed. const rect = form.getBoundingClientRect(); form.style.position = 'fixed'; form.style.left = `${Math.floor(rect.left)}px`; form.style.width = `${Math.floor(rect.width)}px`; form.style.zIndex = '40'; form.style.background = 'var(--background)'; // Safari's visualViewport events are unreliable mid keyboard pan (they // can simply not fire), so track the pan with a rAF loop instead — // cheap math per frame, a style write only when the value changes. let lastTop = Number.NaN; let frame = 0; const track = () => { // iOS standalone (PWA) can serve stale visualViewport metrics after // the keyboard rises (full pre-keyboard height, intermittently), // parking the form behind the keyboard. When interactive-widget // resizes the layout viewport, documentElement.clientHeight is the // true above-keyboard bottom — anchor to whichever is smaller. In // pan-mode browsers clientHeight stays full height, so the min // keeps the visual-viewport anchor there. const layoutBottom = document.documentElement.clientHeight; const vvBottom = vv.offsetTop + vv.height; const top = Math.max(0, Math.floor(Math.min(vvBottom, layoutBottom) - form.offsetHeight)); if (top !== lastTop) { lastTop = top; form.style.top = `${top}px`; } frame = requestAnimationFrame(track); }; track(); return () => { cancelAnimationFrame(frame); form.style.position = ''; form.style.left = ''; form.style.width = ''; form.style.top = ''; form.style.zIndex = ''; form.style.background = ''; }; }, [isMobile, isMobileExpanded, newSessionDraftOpen, mobileTextareaFocused]); // Shared drag handle: rendered at the top of the full composer AND inside // the dictation overlay, so swipe-expand/collapse works in Listening mode. // Memoized so the always-mounted dictation instance's memo stays effective. const mobileComposerHandle = React.useMemo(() => isMobile ? (