diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 7b4469be..30ff57fd 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -995,7 +995,7 @@ export const ChatContainer: React.FC = ({ active = true, aut // No transform on this root: it would become the containing block for // the fullscreen composer's position:fixed visual-viewport pinning in // mobile browsers (see ChatInput's composerFormRef effect). -
+
{useCompactDraftLayout && !isDesktopExpandedInput ? : null}
= ({ active = true, aut if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { if (sessionMessageLoadState.status === 'error') { return ( -
+
{returnToParentButton}
@@ -1041,7 +1041,7 @@ export const ChatContainer: React.FC = ({ active = true, aut ); } return ( -
+
{returnToParentButton}
= ({ active = true, aut return ( // No transform here either — same fixed-positioning constraint as the // draft branch above. -
+
{returnToParentButton}
= ({ active = true, aut } return ( -
+
{returnToParentButton} ): 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; @@ -150,76 +170,16 @@ const renameFileForAttachmentCitation = (file: File, filename: string): File => }); }; -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; -}; +/** + * Skills the user named inline with `/name`. Matched against the registry's + * exact casing, since the name is echoed back to the model as a skill to load. + */ +const collectInlineSkillMentions = (text: string, skillNames: Set): string[] => + collectKnownTokenNames(text, '/', skillNames, 'exact'); const buildSkillMentionInstruction = (skillNames: string[]): string | null => { if (skillNames.length === 0) return null; @@ -231,160 +191,6 @@ 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); @@ -399,543 +205,17 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea ); }; -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 @@ -965,66 +245,31 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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); + // At most one picker is open at a time; the prompt language decides which. + const [openAutocomplete, setOpenAutocomplete] = React.useState(null); + const [autocompleteQuery, setAutocompleteQuery] = React.useState(''); + const closeAutocomplete = React.useCallback(() => setOpenAutocomplete(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 composerRef = React.useRef(null); + // The mobile composer swaps between the collapsed pill and the full + // composer, which unmounts the editor. Building a CodeMirror view is far + // from free, and it would happen inside the tap that expands the pill — + // before the browser may paint the swap. The store keeps one view alive for + // as long as the composer itself is mounted. + const composerViewStore = React.useRef(createComposerEditorViewStore()).current; + React.useEffect(() => () => { + composerViewStore.view?.destroy(); + composerViewStore.view = null; + }, [composerViewStore]); + const composerFormRef = 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); @@ -1039,9 +284,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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()); @@ -1073,7 +315,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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); @@ -1091,9 +332,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo [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); @@ -1134,7 +372,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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, @@ -1296,28 +533,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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(); @@ -1328,85 +543,26 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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]); + const attachmentFilenames = React.useMemo( + () => attachedFiles.map((file) => file.filename), + [attachedFiles], + ); - // @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]); + /** + * Everything the prompt language needs to resolve references. Rebuilt only + * when a registry changes, so typing does not churn the tokenizer input. + */ + const languageContext = React.useMemo(() => ({ + inputMode, + knownAgentNames, + confirmedMentions: confirmedMentionsRef.current, + knownSlashNames, + knownSnippetTriggers, + attachmentFilenames, + }), [attachmentFilenames, inputMode, knownAgentNames, knownSlashNames, knownSnippetTriggers]); const sanitizeAttachmentsForSend = React.useCallback( - (files: AttachedFile[] | undefined): AttachedFile[] => (files ?? []) + (files: readonly AttachedFile[] | undefined): AttachedFile[] => [...(files ?? [])] .map((file) => ({ ...file, dataUrl: file.source === 'server' && file.serverPath @@ -1426,31 +582,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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) { + for (const token of scanMentions(rawText)) { + const mentionPath = token.name; + const kind = classifyMention(mentionPath, { + knownAgentNames: knownAgentNamesRef.current, + confirmedMentions: confirmedMentionsRef.current, + }); + // Agents are routed separately by parseAgentMentions; only file + // references become attachments here. + if (kind !== 'file') { continue; } @@ -1493,7 +633,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo attachments, }; }, [chatSearchDirectory]); - const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState(null); const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); @@ -1604,7 +743,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // User message history for up/down arrow navigation. // Keep this on a narrow hook instead of full session message records. - const userMessageHistory = useUserMessageHistory(currentSessionId ?? ""); + const messageHistory = useMessageHistory(useUserMessageHistory(currentSessionId ?? "")); // Keep messageRef in sync with message state React.useEffect(() => { @@ -1615,96 +754,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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]); + // Draft persistence: identity switching, debounced writes and the + // flush-on-hide edges live in the hook. + const { persistNow: persistDraftImmediately } = useComposerDraft({ + message, + messageRef, + setMessage, + confirmedMentionsRef, + identity: chatDraftIdentity, + persistEnabled: persistChatDraft, + initialDraft: { + text: initialDraftRef.current ?? '', + identity: initialDraftIdentityRef.current, + }, + onIdentityChange: () => setInputMode('normal'), + onDraftRestored: () => composerRef.current?.selectAll(), + }); // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); @@ -1714,63 +779,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo requestAnimationFrame(() => { if (isMobile) { // On mobile, use preventScroll to avoid viewport jumping - textareaRef.current?.focus({ preventScroll: true }); + composerRef.current?.focus({ preventScroll: true }); } else { - textareaRef.current?.focus(); + composerRef.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) => { @@ -1788,7 +805,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // keyboard-close lands, otherwise the composer folds into the pill // under the sheet. setMobileControlsPanel(panel); - textareaRef.current?.blur(); + composerRef.current?.blur(); }, [isMobile]); // Consume pending input text (e.g., from revert action) @@ -1809,7 +826,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } // Focus textarea after setting message setTimeout(() => { - textareaRef.current?.focus(); + composerRef.current?.focus(); }, 0); } } @@ -1822,7 +839,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const canAbort = sessionPhase !== 'idle'; const getCurrentInputSnapshot = React.useCallback(() => { - const currentMessage = textareaRef.current?.value ?? message; + const currentMessage = composerRef.current?.getValue() ?? message; return { message: currentMessage, hasContent: currentMessage.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, @@ -1875,14 +892,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } if (!isMobile) { - textareaRef.current?.focus(); + composerRef.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(); + composerRef.current?.focus(); }, 0); }, []); @@ -1966,132 +983,48 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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.) + // Inline review comments and synthetic context are consumed before + // assembly so a failed send can restore exactly what it took. const syntheticParts = consumePendingSyntheticParts(); + const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget; + const drafts: InlineCommentDraft[] = consumedDraftTarget + ? consumeDrafts(consumedDraftTarget) + : []; - // 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); + const availableSkillNames = new Set( + useSkillsStore.getState().skills.map((skill) => skill.name), + ); - // Use agent mention from first message that has one - if (!agentMentionName && mention?.name) { - agentMentionName = mention.name; - } + const outgoing = buildOutgoingMessage({ + queued: queuedMessagesToSend, + composerText: !queuedOnly && inputSnapshot.hasContent ? inputSnapshot.message : null, + composerAttachments: attachedFiles, + inlineComments: drafts, + syntheticTexts: syntheticParts?.map((part) => part.text) ?? [], + linkedIssueContext: linkedIssue?.contextText ?? null, + linkedPr: linkedPr + ? { instructions: linkedPr.instructionsText, context: linkedPr.contextText } + : null, + }, { + parseAgentMention: (text) => { + const { sanitizedText, mention } = parseAgentMentions(text, agents); + return { text: sanitizedText, agentName: mention?.name }; + }, + extractFileMentions: (text) => { + const { sanitizedText, attachments } = extractInlineFileMentions(text); + return { text: sanitizedText, attachments }; + }, + sanitizeAttachments: sanitizeAttachmentsForSend, + collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames), + appendComments: (text, comments) => + appendInlineComments(text, comments as InlineCommentDraft[]), + buildSkillInstruction: buildSkillMentionInstruction, + }); - 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], - }); - } - } + let primaryText = outgoing.primaryText; + const { primaryAttachments, additionalParts, agentMentionName } = outgoing; - // 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; + if (outgoing.isEmpty) return; // Clear queue and input if (messageQueueTarget && queuedMessageId) { @@ -2104,9 +1037,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo confirmedMentionsRef.current.clear(); // Clear per-session draft on submit persistDraftImmediately(chatDraftIdentity, ''); - // Reset message history navigation state - setHistoryIndex(-1); - setDraftMessage(''); + messageHistory.reset(); if (attachedFiles.length > 0) { clearAttachedFiles(); } @@ -2115,33 +1046,35 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } if (isMobile) { - textareaRef.current?.blur(); + composerRef.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(); + // Local slash commands, normal mode only. + const parsedCommand = inputMode === 'normal' ? parseSlashCommand(primaryText) : null; + if (parsedCommand) { + const { name: commandName, argument } = parsedCommand; + // Commands that manipulate session state or open UI rather than + // sending a message. if (commandName === 'undo' && currentSessionId) { await useSessionUIStore.getState().handleSlashUndo(currentSessionId); scrollToBottom?.(); return; } - else if (commandName === 'redo' && currentSessionId) { + if (commandName === 'redo' && currentSessionId) { await useSessionUIStore.getState().handleSlashRedo(currentSessionId); scrollToBottom?.(); return; } - else if (commandName === 'timeline' && currentSessionId) { + if (commandName === 'timeline' && currentSessionId) { setTimelineDialogOpen(true); return; } - else if (commandName === 'compact' && currentSessionId) { + if (commandName === 'handoff-review' && currentSessionId && !isMobile && !isVSCodeRuntime()) { + setReviewDialogOpen(true); + return; + } + if (commandName === 'compact' && currentSessionId) { try { await sessionActions.waitForConnectionOrThrow(); const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined; @@ -2151,18 +1084,20 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } return; } - else if (commandName === 'summary' && currentSessionId) { + + // The rest render a visible prompt plus synthetic instructions and + // send them as one message. + const command = findMagicPromptCommand(commandName); + const commandIsAvailable = command !== null && canRunCommand(command, { + hasSession: Boolean(currentSessionId), + hasDraft: newSessionDraftOpen, + }); + if (command && commandIsAvailable) { + const variables = buildCommandVariables(command, argument); 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 }); + const visibleText = await renderMagicPrompt(command.visiblePrompt, variables.visible); + const instructionsText = await renderMagicPrompt(command.instructionsPrompt, variables.instructions); await sendMessage( visibleText, providerIdToSend, @@ -2177,201 +1112,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); 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 === 'schedule-task' && (currentSessionId || newSessionDraftOpen)) { - try { - await sessionActions.waitForConnectionOrThrow(); - const idea = normalizedCommand.replace(/^\/schedule-task\b/i, '').trim(); - const visibleText = await renderMagicPrompt('session.scheduleTask.visible', { - idea_block: idea ? `\n\nHere is my initial idea:\n${idea}` : '', - }); - const instructionsText = await renderMagicPrompt('session.scheduleTask.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.scheduleTaskFailed')); - } - 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')); + toast.error(error instanceof Error ? error.message : t(command.errorToastKey)); } return; } @@ -2453,7 +1194,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo console.error('Message send failed:', rawMessage || error); restoreConsumedDrafts(); - const currentInput = textareaRef.current?.value ?? messageRef.current; + const currentInput = composerRef.current?.getValue() ?? messageRef.current; if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { setMessage(inputSnapshot.message); writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); @@ -2493,7 +1234,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); if (!isMobile) { - textareaRef.current?.focus(); + composerRef.current?.focus(); } }; @@ -2518,25 +1259,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // 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 draft = (composerRef.current?.getValue() ?? 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 + // getCurrentInputSnapshot reads composerRef.current.getValue() 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; + // The editor is controlled by this state; getCurrentInputSnapshot + // reads it back, so no imperative write is needed. + return appendInlineText(prev, text); }); setTimeout(() => { - textareaRef.current?.focus(); + composerRef.current?.focus(); }, 0); }, []); @@ -2544,7 +1282,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // 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); + const next = appendInlineText(composerRef.current?.getValue() ?? messageRef.current, text); void handleSubmitRef.current({ presetText: next }); }, []); @@ -2557,7 +1295,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (text) submitPresetPrompt(text); }, [pendingPresetSubmit, submitPresetPrompt]); - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = (e: 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; @@ -2574,52 +1312,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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 (openAutocomplete === 'command' && commandRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2628,7 +1321,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (showSkillAutocomplete && skillRef.current) { + if (openAutocomplete === 'skill' && skillRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2637,7 +1330,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (showSnippetAutocomplete && snippetRef.current) { + if (openAutocomplete === 'snippet' && snippetRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2646,7 +1339,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (showFileMention && mentionRef.current) { + if (openAutocomplete === 'mention' && mentionRef.current) { if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); @@ -2670,7 +1363,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? 1 : 0; - if (cycleAgentDirection !== 0 && !showCommandAutocomplete && !showSkillAutocomplete && !showSnippetAutocomplete && !showFileMention) { + if (cycleAgentDirection !== 0 && openAutocomplete === null) { e.preventDefault(); e.stopPropagation(); handleCycleAgent(cycleAgentDirection); @@ -2680,30 +1373,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo // 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 isAnyAutocompleteOpen = openAutocomplete !== null; + const cursorAtStart = composerRef.current?.getSelection().start === 0 && composerRef.current?.getSelection().end === 0; + const cursorAtEnd = composerRef.current?.getSelection().start === message.length && composerRef.current?.getSelection().end === 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; + const ta = composerRef.current; + const selStart = ta?.getSelection().start ?? -1; + const selEnd = ta?.getSelection().end ?? -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(); - }); + composerRef.current?.setSelection(caretStart, caretEnd); updateAutocompleteState(next, caretEnd); }; @@ -2736,40 +1422,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } } - if (e.key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory.length > 0) { + if (e.key === 'ArrowUp' && canNavigateHistoryUp) { 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]); + const recalled = messageHistory.older(message); + if (recalled !== null) { + setMessage(recalled); + // Caret to the start, so the recalled message reads from its + // beginning rather than from wherever the draft's caret was. + requestAnimationFrame(() => composerRef.current?.setSelection(0, 0)); } - // 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) { + if (e.key === 'ArrowDown' && canNavigateHistoryDown) { 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]); - } + const recalled = messageHistory.newer(); + if (recalled !== null) setMessage(recalled); return; } @@ -2800,128 +1468,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }; - 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, + // Focus mode places the open picker at the caret; elsewhere each picker + // anchors to the composer itself. + const { + position: autocompleteOverlayPosition, + update: updateAutocompleteOverlayPosition, + } = useAutocompletePosition({ + enabled: isDesktopExpanded, + openAutocomplete, 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]); + editorRef: composerRef, + containerRef: dropZoneRef, + }); const startAbortIndicator = React.useCallback(() => { if (abortTimeoutRef.current) { @@ -2955,168 +1513,29 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } }, [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); + // Height the dictation transcript needs (null when idle). Its overlay sits + // absolutely over the composer, so the composer must be able to grow for + // it. The editor sizes itself to its own content; this is the one external + // constraint, applied as a floor on the editor's container. 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 trigger = resolveAutocompleteTrigger(value, cursorPosition, { + inputMode, + inputSource, + insertedText, + }); + setOpenAutocomplete(trigger?.kind ?? null); + setAutocompleteQuery(trigger?.query ?? ''); + }, [inputMode]); const insertTextAtSelection = React.useCallback(( text: string, @@ -3126,32 +1545,25 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - const textarea = textareaRef.current; - if (!textarea) { + const editor = composerRef.current; + if (!editor) { + // No mounted editor (collapsed mobile pill): append to the state + // the editor will be seeded from. 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 { start, end } = editor.getSelection(); 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(); - }); - + // One dispatch places both the text and the caret, so there is no + // frame where the caret sits at a stale offset. + editor.insertText(text); updateAutocompleteState(nextValue, cursorPosition, inputSource, text); - }, [adjustTextareaHeight, message, updateAutocompleteState]); + }, [message, updateAutocompleteState]); const clearDropTextSuppression = React.useCallback(() => { suppressNextFileDropTextInsertRef.current = false; @@ -3190,65 +1602,37 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }, 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; + const handleComposerChange = ({ value, selection, fromPaste, insertedText }: ComposerChange) => { + // VS Code drops the dragged path as text as well as firing the drop + // handler; swallow that duplicate insertion. 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) { + if (candidateAbsolutePaths.some((path) => path.length > 0 && value.includes(path))) { 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 pastedInsertedText = fromPaste ? insertedText : ''; const isPasteInput = pastedInsertedText.includes('@') || suppressNextFileMentionPasteRef.current; if (suppressNextFileMentionPasteRef.current) { clearFileMentionPasteSuppression(); } - const inputSource: FileMentionAutocompleteInputSource = isPasteInput - ? 'paste' - : 'manual'; + const inputSource: FileMentionAutocompleteInputSource = isPasteInput ? 'paste' : 'manual'; + // A leading `!` switches the composer into shell mode and is consumed. if (inputMode === 'normal' && value.startsWith('!')) { const shellCommand = value.slice(1); - const nextCursor = Math.max(0, cursorPosition - 1); + const nextCursor = Math.max(0, selection.start - 1); setInputMode('shell'); setMessage(shellCommand); - adjustTextareaHeight(); - setShowCommandAutocomplete(false); - setShowSkillAutocomplete(false); - setShowFileMention(false); - requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; - } - }); + closeAutocomplete(); + requestAnimationFrame(() => composerRef.current?.setSelection(nextCursor)); return; } setMessage(value); - adjustTextareaHeight(); - updateAutocompleteState(value, cursorPosition, inputSource, pastedInsertedText); + updateAutocompleteState(value, selection.start, inputSource, pastedInsertedText); }; React.useEffect(() => { @@ -3258,35 +1642,29 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }; }, [clearDropTextSuppression, clearFileMentionPasteSuppression]); - const handlePaste = React.useCallback(async (e: React.ClipboardEvent) => { + const handlePaste = React.useCallback(async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + if (!clipboardData) return; + // Narrowed alias so the rest of the handler reads as it did when this + // was a React synthetic event, whose clipboardData is never null. + const e = { ...event, clipboardData, preventDefault: () => event.preventDefault() }; + // 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; + const ta = composerRef.current; + const selStart = ta?.getSelection().start ?? -1; + const selEnd = ta?.getSelection().end ?? -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('](') - ) { + if (shouldWrapSelectionAsLink(url, selected)) { 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(); - }); + composerRef.current?.setSelection(caret, caret); updateAutocompleteState(next, caret, getFileMentionInputSourceForInsertedText(url), url); return; } @@ -3336,9 +1714,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ], ); const citationText = buildAttachmentCitationText(assignedFilenames); - const textarea = textareaRef.current; - const selectionStart = textarea?.selectionStart ?? message.length; - const selectionEnd = textarea?.selectionEnd ?? message.length; + const textarea = composerRef.current; + const selectionStart = textarea?.getSelection().start ?? message.length; + const selectionEnd = textarea?.getSelection().end ?? message.length; const insertionText = withInlineInsertionBoundaries( buildImagePasteInsertion(pastedText, citationText), message.slice(0, selectionStart), @@ -3360,17 +1738,17 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo pendingPastedAttachmentFilenamesRef.current.delete(filename); } } - }, [addAttachedFile, attachedFiles, adjustTextareaHeight, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); + }, [addAttachedFile, attachedFiles, 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 cursorPosition = composerRef.current?.getSelection().start || 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); + : (toMentionPath(file.path) || file.name); confirmedMentionsRef.current.add(mentionPath); @@ -3382,14 +1760,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setMessage(newMessage); const nextCursor = lastAtSymbol + mentionPath.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); - } else if (textareaRef.current) { + } else if (composerRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${mentionPath} ` + @@ -3397,24 +1773,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setMessage(newMessage); const nextCursor = cursorPosition + mentionPath.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } - setShowFileMention(false); - setMentionQuery(''); + closeAutocomplete(); - textareaRef.current?.focus(); + composerRef.current?.focus(); }; const handleAgentSelect = (agentName: string) => { - const textarea = textareaRef.current; - const cursorPosition = textarea?.selectionStart ?? message.length; + const textarea = composerRef.current; + const cursorPosition = textarea?.getSelection().start ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastAtSymbol = textBeforeCursor.lastIndexOf('@'); @@ -3427,14 +1800,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const nextCursor = lastAtSymbol + agentName.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); - } else if (textareaRef.current) { + } else if (composerRef.current) { const newMessage = message.substring(0, cursorPosition) + `@${agentName} ` + @@ -3443,24 +1814,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const nextCursor = cursorPosition + agentName.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } - setShowFileMention(false); - setMentionQuery(''); + closeAutocomplete(); - textareaRef.current?.focus(); + composerRef.current?.focus(); }; const handleSkillSelect = (skillName: string) => { - const textarea = textareaRef.current; - const cursorPosition = textarea?.selectionStart ?? message.length; + const textarea = composerRef.current; + const cursorPosition = textarea?.getSelection().start ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastSlashSymbol = textBeforeCursor.lastIndexOf('/'); @@ -3473,24 +1841,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const nextCursor = lastSlashSymbol + skillName.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); } - setShowSkillAutocomplete(false); - setSkillQuery(''); + closeAutocomplete(); - textareaRef.current?.focus(); + composerRef.current?.focus(); }; const handleSnippetSelect = (_snippet: unknown, trigger: string) => { - const textarea = textareaRef.current; - const cursorPosition = textarea?.selectionStart ?? message.length; + const textarea = composerRef.current; + const cursorPosition = textarea?.getSelection().start ?? message.length; const textBeforeCursor = message.substring(0, cursorPosition); const lastHashSymbol = textBeforeCursor.lastIndexOf('#'); const startIndex = lastHashSymbol !== -1 ? lastHashSymbol : cursorPosition; @@ -3498,38 +1863,29 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setMessage(newMessage); const nextCursor = startIndex + trigger.length + 2; requestAnimationFrame(() => { - if (textareaRef.current) { - textareaRef.current.selectionStart = nextCursor; - textareaRef.current.selectionEnd = nextCursor; + if (composerRef.current) { + composerRef.current.setSelection(nextCursor); } - adjustTextareaHeight(); updateAutocompleteState(newMessage, nextCursor); }); - setShowSnippetAutocomplete(false); - setSnippetQuery(''); - textareaRef.current?.focus(); + closeAutocomplete(); + composerRef.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(''); + closeAutocomplete(); const refocus = () => { - if (textareaRef.current) { + if (composerRef.current) { try { - textareaRef.current.focus({ preventScroll: true }); + composerRef.current.focus({ preventScroll: true }); } catch { - textareaRef.current.focus(); + composerRef.current.focus(); } - textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length); + composerRef.current.setSelection(composerRef.current.getValue().length, composerRef.current.getValue().length); } }; @@ -3542,8 +1898,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo React.useEffect(() => { - if (currentSessionId && textareaRef.current && !isMobile) { - textareaRef.current.focus(); + if (currentSessionId && composerRef.current && !isMobile) { + composerRef.current.focus(); } }, [currentSessionId, isMobile]); @@ -3563,118 +1919,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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]); + // Mention paths are shown relative to the project the chat searches. + const toMentionPath = React.useCallback( + (absolutePath: string) => toProjectRelativeMentionPath(absolutePath, chatSearchDirectory || ""), + [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) => toMentionPath(entry)) .map((entry) => entry.trim().replace(/^\.\//, '')) .filter((entry) => entry.length > 0); @@ -3690,7 +1946,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo setPendingInputText(mentions.join(' '), 'append-inline'); toast.success(t('chat.chatInput.toast.addedFileMentions', { count: mentions.length })); - }, [normalizeDroppedPath, setPendingInputText, t, toProjectRelativeMentionPath]); + }, [setPendingInputText, t, toMentionPath]); const handleDragEnter = (e: React.DragEvent) => { if (!hasDraggedFiles(e.dataTransfer)) { @@ -3757,25 +2013,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (internalPath && internalPath !== '.') { confirmedMentionsRef.current.add(internalPath); const mention = `@${internalPath}`; - const textarea = textareaRef.current; + const textarea = composerRef.current; const currentMessage = messageRef.current; if (textarea) { - const pos = textarea.selectionStart ?? cursorPosRef.current; - const end = textarea.selectionEnd ?? pos; + const { start: pos, end } = textarea.getSelection(); 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(); - }); + // Insert through the editor rather than setMessage: an editor + // dispatch places the caret right after the mention, while the + // external-rewrite path would send it to the end of the + // message and pin the scroll to the bottom. + textarea.replaceRange(pos, end, insert); + cursorPosRef.current = pos + insert.length; + textarea.focus(); } else { setMessage((prev) => appendInlineText(prev, mention)); } @@ -3908,169 +2161,21 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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]); + // Which project and directory a new session will target. + const { + projects: draftProjects, + selectedDraftProject, + draftProjectLabel, + selectedDraftDirectory, + selectedDraftBranchLabel, + selectedDraftBranchIsKnown, + projectRootBranchOption, + worktreeBranchOptions, + draftBranchItems, + shouldShowDraftBranchSelector, + handleDraftProjectChange, + handleDraftDirectoryChange, + } = useDraftTarget(showDraftTargetSelectors); const chatSurfaceMode = useChatSurfaceMode(); const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; @@ -4085,110 +2190,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo 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) { @@ -4207,31 +2208,35 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }); }, [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() }); - }, []); + + // Mobile pill composer: the collapse/expand state machine and the + // platform corrections that keep it from fighting the soft keyboard. + const mobileShell = useMobileComposerShell({ + isMobile, + editorRef: composerRef, + formRef: composerFormRef, + setExpandedInput, + holders: { + controlsPanelOpen: Boolean(mobileControlsPanel), + attachMenuOpen: mobileAttachMenuOpen, + draftPickerOpen: mobileDraftPicker !== null, + issuePickerOpen, + prPickerOpen, + isDragging, + }, + }); + const mobileComposerExpanded = mobileShell.expanded; + const mobileTextareaFocused = mobileShell.focused; + const applyAssistSuggestion = React.useCallback((text: string) => { setMessage(text); if (isMobile && !mobileComposerExpanded) { - expandMobileComposer('focus'); + mobileShell.expand(); } else { - requestAnimationFrame(() => textareaRef.current?.focus()); + requestAnimationFrame(() => composerRef.current?.focus()); } - }, [expandMobileComposer, isMobile, mobileComposerExpanded]); + }, [isMobile, mobileComposerExpanded, mobileShell]); const handleMobileNewSession = React.useCallback(() => { @@ -4239,420 +2244,36 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined); }, [newSessionDraftOpen, openNewSessionDraft, currentDirectory]); + /** The dictation engine listens for this globally; the composer only asks. */ + const toggleDictation = React.useCallback(() => { + window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle')); + }, []); + 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(); + composerRef.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 ? ( -