2025-12-07 19:32:53 +02:00
import React from 'react' ;
2026-07-04 02:48:07 +03:00
import { ComposerDictation } from '@/components/dictation/ComposerDictation' ;
2026-03-31 18:47:00 +03:00
// sessionStore removed — currentSessionId comes from useSessionUIStore
2025-12-07 19:32:53 +02:00
import { useConfigStore } from '@/stores/useConfigStore' ;
import { useUIStore } from '@/stores/useUIStore' ;
2026-07-21 20:52:20 +03:00
import { createMessageQueueTarget , getMessageQueueKey , useMessageQueueStore , type QueuedMessage } from '@/stores/messageQueueStore' ;
2026-06-26 19:29:44 +03:00
import { useAutoReviewStore } from '@/stores/useAutoReviewStore' ;
2026-03-31 18:47:00 +03:00
import { useSessionUIStore } from '@/sync/session-ui-store' ;
import { useSelectionStore } from '@/sync/selection-store' ;
2026-07-22 11:54:03 +03:00
import { useInputStore } from '@/sync/input-store' ;
2026-07-22 14:40:40 +03:00
import {
ACCEPTED_ATTACHMENT_EXTENSIONS ,
ATTACHMENT_ACCEPT ,
getUnsupportedAttachmentInputs ,
type AttachmentInputModality ,
} from '@/sync/attachment-files' ;
2026-02-01 18:29:34 +02:00
import type { AttachedFile } from '@/stores/types/sessionTypes' ;
2026-03-31 18:47:00 +03:00
import * as sessionActions from '@/sync/session-actions' ;
2026-07-27 22:21:38 +03:00
import { useUserMessageHistory } from "@/sync/sync-context" ;
2026-07-21 20:52:20 +03:00
import { getInlineCommentDraftKey , useInlineCommentDraftStore , type InlineCommentDraft , type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore' ;
2026-05-21 20:00:35 +03:00
import { useSnippetsStore } from '@/stores/useSnippetsStore' ;
2026-02-05 03:14:26 +02:00
import { appendInlineComments } from '@/lib/messages/inlineComments' ;
2026-04-22 21:56:35 +03:00
import { renderMagicPrompt } from '@/lib/magicPrompts' ;
2026-06-07 01:22:40 +03:00
import { startReviewFlow } from '@/lib/reviewFlow' ;
2026-06-26 19:29:44 +03:00
import { getRuntimeKey } from '@/lib/runtime-switch' ;
2026-07-21 20:52:20 +03:00
import {
createChatDraftIdentity ,
readChatDraft ,
writeChatDraft ,
type ChatDraftIdentity ,
type ChatDraftSnapshot ,
} from '@/lib/chatDraftPersistence' ;
2026-06-14 16:55:58 +03:00
import { ReviewFlowDialog , type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog' ;
2026-05-05 00:24:03 +02:00
import { AttachedFilesList , AttachedVSCodeFileChips , ActiveEditorFileSuggestion } from './FileAttachment' ;
2026-05-24 23:18:32 +03:00
import ToolOutputDialog from './message/ToolOutputDialog' ;
import type { ToolPopupContent } from './message/types' ;
2025-12-29 02:16:42 +02:00
import { QueuedMessageChips } from './QueuedMessageChips' ;
2026-06-26 19:29:44 +03:00
import { AutoReviewBanner } from './AutoReviewBanner' ;
2026-07-27 22:21:38 +03:00
import type { FileMentionHandle } from './FileMentionAutocomplete' ;
import type { CommandAutocompleteHandle , CommandInfo } from './CommandAutocomplete' ;
import type { SkillAutocompleteHandle } from './SkillAutocomplete' ;
import type { SnippetAutocompleteHandle } from './SnippetAutocomplete' ;
import { cn } from "@/lib/utils" ;
2025-12-07 19:32:53 +02:00
import { ModelControls } from './ModelControls' ;
import { parseAgentMentions } from '@/lib/messages/agentMentions' ;
2025-12-16 02:34:29 +02:00
import { StatusRow } from './StatusRow' ;
2026-04-22 03:34:06 +08:00
import { PendingChangesBar } from './PendingChangesBar' ;
2026-05-08 12:22:59 +03:00
import { useChatSurfaceMode } from './useChatSurfaceMode' ;
2026-02-05 00:07:24 +08:00
import { MobileAgentButton } from './MobileAgentButton' ;
import { MobileModelButton } from './MobileModelButton' ;
2025-12-29 02:16:42 +02:00
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity' ;
2026-01-19 02:50:40 +02:00
import { toast } from '@/components/ui' ;
2026-03-31 18:47:00 +03:00
// useMessageStore removed — messages now come from sync system
2026-06-03 02:42:00 +03:00
import { isVSCodeRuntime } from '@/lib/desktop' ;
2026-08-02 16:25:16 +03:00
import { useTabletLayout } from '@/lib/device' ;
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard' ;
2026-01-06 21:31:04 +02:00
import { isIMECompositionEvent } from '@/lib/ime' ;
2026-04-27 04:28:51 -06:00
import { getCycledPrimaryAgentName , type MobileControlsPanel } from './mobileControlsUtils' ;
2026-07-04 16:41:17 +03:00
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel' ;
2026-02-01 18:29:34 +02:00
import { useThemeSystem } from '@/contexts/useThemeSystem' ;
2026-03-03 00:20:15 +02:00
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog' ;
2026-03-04 01:41:01 +02:00
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog' ;
2026-05-13 13:26:15 +03:00
import { Icon } from "@/components/icon/Icon" ;
2026-05-29 13:31:07 +03:00
import { DraftPresetChips } from './DraftPresetChips' ;
2026-03-04 01:41:01 +02:00
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory' ;
import { opencodeClient } from '@/lib/opencode/client' ;
2026-07-27 22:21:38 +03:00
import { useGitStore , useIsGitRepo } from '@/stores/useGitStore' ;
2026-04-23 10:55:27 +03:00
import { useDirectoryStore } from '@/stores/useDirectoryStore' ;
2026-05-17 00:47:51 +03:00
import { useSkillsStore } from '@/stores/useSkillsStore' ;
2026-05-24 17:21:11 +03:00
import { useCommandsStore } from '@/stores/useCommandsStore' ;
2026-03-20 01:01:03 +02:00
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs' ;
2026-06-15 15:26:08 +03:00
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory' ;
2026-03-20 01:01:03 +02:00
import { usePermissionStore } from '@/stores/permissionStore' ;
2026-07-11 23:15:02 +11:00
import { togglePermissionAutoAccept } from './permissionAutoAccept' ;
2026-04-23 10:55:27 +03:00
import { extractGitChangedFiles } from './changedFiles' ;
2026-04-26 14:03:39 +03:00
import { useI18n } from '@/lib/i18n' ;
2026-06-15 15:10:12 +03:00
import { sessionEvents } from '@/lib/sessionEvents' ;
2026-05-01 01:27:31 +03:00
import { fetchResponseStyleInstruction } from '@/lib/responseStyle' ;
2026-05-07 19:35:46 +03:00
import { wrapSystemReminder } from '@/lib/systemReminder' ;
2026-05-01 01:27:31 +03:00
import { getSyncMessages } from '@/sync/sync-refs' ;
2026-05-15 00:27:39 +03:00
import { eventMatchesShortcut , getEffectiveShortcutCombo , normalizeCombo } from '@/lib/shortcuts' ;
2026-05-24 23:47:22 +03:00
import {
assignImageAttachmentFilenames ,
buildAttachmentCitationText ,
} from './attachmentCitations' ;
2026-07-27 22:21:38 +03:00
import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState' ;
import {
classifyMention ,
scanMentions ,
} from './composer/language/mentions' ;
import { collectKnownTokenNames } from './composer/language/prefixTokens' ;
import { resolveAutocompleteTrigger , type AutocompleteKind } from './composer/language/triggers' ;
import { type ComposerLanguageContext } from './composer/language/tokenize' ;
import {
ComposerEditor ,
type ComposerChange ,
type ComposerEditorHandle ,
} from './composer/editor/ComposerEditor' ;
import { createComposerEditorViewStore } from './composer/editor/viewStore' ;
import {
appendInlineText ,
appendWithLineBreaks ,
buildImagePasteInsertion ,
shouldWrapSelectionAsLink ,
withInlineInsertionBoundaries ,
} from './composer/text' ;
import {
collectDroppedFileUris ,
collectDroppedFiles ,
hasDraggedFiles ,
} from './composer/attachments/dataTransfer' ;
import {
normalizeDroppedPath ,
normalizePath ,
toProjectRelativeMentionPath ,
toServerFileUrl ,
} from './composer/attachments/filePaths' ;
import { buildOutgoingMessage } from './composer/submit/buildOutgoingMessage' ;
import {
buildCommandVariables ,
canRunCommand ,
findMagicPromptCommand ,
parseSlashCommand ,
} from './composer/submit/slashCommands' ;
import { useAutocompletePosition } from './composer/state/useAutocompletePosition' ;
import { useMessageHistory } from './composer/state/useMessageHistory' ;
import { useComposerDraft } from './composer/state/useComposerDraft' ;
import { useDraftTarget } from './composer/state/useDraftTarget' ;
import { useMobileComposerShell } from './composer/state/useMobileComposerShell' ;
import { useMobileViewportPin } from './composer/state/useMobileViewportPin' ;
import {
DraftTargetSelectors ,
MobileDraftTargetSheets ,
MobileDraftTargetTriggers ,
} from './composer/ui/DraftTargetSelectors' ;
import { ComposerAutocompletePopups } from './composer/ui/ComposerAutocompletePopups' ;
import { ComposerFooter } from './composer/ui/ComposerFooter' ;
import { MobilePillComposer } from './composer/ui/MobilePillComposer' ;
import { ComposerContextChips } from './composer/ui/ComposerContextChips' ;
import { LinkedReferenceRow } from './composer/ui/LinkedReferenceRow' ;
import { RevertedMessageDock } from './composer/ui/RevertedMessageDock' ;
2026-07-05 23:19:10 +03:00
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip' ;
2026-07-12 01:23:22 +03:00
import { SessionGoalRow } from '@/components/chat/SessionGoalRow' ;
2025-12-07 19:32:53 +02:00
2026-07-27 22:21:38 +03:00
const MAX_VISIBLE_COMPOSER_LINES = 8 ;
/**
* Mobile grows the composer with content instead of offering a fullscreen
* gesture — the old swipe-up handle bought barely a line of extra height.
* The real ceiling is measured: the editor may grow until the composer fills
* its screen container (marked data-composer-bound in ChatContainer), with
* the chrome around the editor read from the DOM. The line cap only stops
* absurdly tall editors on tablets.
*/
const MAX_MOBILE_COMPOSER_LINES = 16 ;
/**
* Breathing room between the fully grown composer and the top of its screen
* container: without it the composer's border lands exactly on the header's
* bottom edge on the chat screen. A visual gap by design, not an estimate.
*/
const MOBILE_COMPOSER_BOUND_GAP_PX = 4 ;
2025-12-29 02:16:42 +02:00
const EMPTY_QUEUE : QueuedMessage [] = [];
2026-08-03 23:14:14 +03:00
const EMPTY_SENDING_IDS : string [] = [];
2026-05-21 20:00:35 +03:00
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560 ;
2026-05-24 23:47:22 +03:00
const renameFileForAttachmentCitation = ( file : File , filename : string ) : File => {
if ( file . name === filename ) {
return file ;
}
return new File ([ file ], filename , {
type : file . type ,
lastModified : file.lastModified ,
});
};
2026-06-23 07:07:09 -04:00
const getFileMentionInputSourceForInsertedText = ( insertedText : string ) : FileMentionAutocompleteInputSource => (
insertedText . includes ( '@' ) ? 'paste' : 'manual'
);
2026-07-27 22:21:38 +03:00
/**
* 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 >) : string [] =>
collectKnownTokenNames ( text , '/' , skillNames , 'exact' );
2026-05-17 00:47:51 +03:00
const buildSkillMentionInstruction = ( skillNames : string []) : string | null => {
if ( skillNames . length === 0 ) return null ;
const formatted = skillNames . map (( name ) => `/ ${ name } ` ). join ( ', ' );
return `The user explicitly mentioned these skills in their message: ${ formatted } . Use the corresponding skill tool when it is relevant to accomplishing the user's request.` ;
};
2026-05-01 01:27:31 +03:00
const hasUserMessages = ( sessionId : string , directory? : string ) => {
return getSyncMessages ( sessionId , directory ). some (( message ) => message . role === 'user' );
};
2026-05-28 01:25:55 +03:00
const renderDraftTitle = ( title : string , projectLabel : string | null ) : React . ReactNode => {
if ( ! projectLabel ) return title ;
const projectIndex = title . indexOf ( projectLabel );
if ( projectIndex === - 1 ) return title ;
return (
<>
{ title . slice ( 0 , projectIndex )}
< span className = "font-medium" >{ projectLabel }</ span >
{ title . slice ( projectIndex + projectLabel . length )}
</>
);
};
2026-04-04 02:19:55 +03:00
const MemoModelControls = React . memo ( ModelControls );
2026-07-04 02:48:07 +03:00
const MemoComposerDictation = React . memo ( ComposerDictation );
2026-04-04 02:19:55 +03:00
const MemoMobileAgentButton = React . memo ( MobileAgentButton );
const MemoMobileModelButton = React . memo ( MobileModelButton );
const MemoStatusRow = React . memo ( StatusRow );
2025-12-07 19:32:53 +02:00
interface ChatInputProps {
onOpenSettings ?: () => void ;
2026-05-08 14:20:16 +03:00
scrollToBottom ?: () => void ;
2025-12-07 19:32:53 +02:00
}
2026-07-21 20:52:20 +03:00
const resolveChatDraftIdentity = ( sessionId : string | null ) : ChatDraftIdentity | null => {
const sessionState = useSessionUIStore . getState ();
const newSessionDirectory = sessionState . newSessionDraft ? . open
? sessionState . newSessionDraft . bootstrapPendingDirectory ?? sessionState.newSessionDraft.directoryOverride
: null ;
const directory = sessionId
? sessionState . getDirectoryForSession ( sessionId ) ?? sessionState.currentSessionDirectory
: newSessionDirectory ?? useDirectoryStore . getState (). currentDirectory ;
return createChatDraftIdentity ( getRuntimeKey (), directory , sessionId );
2026-04-22 01:31:14 +08:00
};
2026-04-05 15:36:11 +03:00
const ChatInputComponent : React.FC < ChatInputProps > = ({ onOpenSettings , scrollToBottom }) => {
2026-04-26 14:03:39 +03:00
const { t } = useI18n ();
2026-02-06 01:14:09 -08:00
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React . useRef < string | null >( null );
2026-07-21 20:52:20 +03:00
const initialDraftIdentityRef = React . useRef < ChatDraftIdentity | null >( null );
const initialDraftSnapshotRef = React . useRef < ChatDraftSnapshot >({ text : '' , confirmedMentions : new Set () });
2026-02-06 01:14:09 -08:00
const [ message , setMessage ] = React . useState (() => {
2026-03-31 18:47:00 +03:00
const sessionId = useSessionUIStore . getState (). currentSessionId ;
2026-07-21 20:52:20 +03:00
const identity = resolveChatDraftIdentity ( sessionId );
const snapshot = readChatDraft ( identity );
initialDraftIdentityRef . current = identity ;
initialDraftSnapshotRef . current = snapshot ;
if ( snapshot . text ) {
initialDraftRef . current = snapshot . text ;
}
return snapshot . text ;
2026-02-06 01:14:09 -08:00
});
2026-07-21 20:52:20 +03:00
const confirmedMentionsRef = React . useRef < Set < string >>( initialDraftSnapshotRef . current . confirmedMentions );
2026-02-18 20:08:42 +02:00
const [ inputMode , setInputMode ] = React . useState < 'normal' | 'shell' > ( 'normal' );
2025-12-07 19:32:53 +02:00
const [ isDragging , setIsDragging ] = React . useState ( false );
2026-04-22 01:31:14 +08:00
const [ isInternalDrag , setIsInternalDrag ] = React . useState ( false );
2026-07-27 22:21:38 +03:00
// At most one picker is open at a time; the prompt language decides which.
const [ openAutocomplete , setOpenAutocomplete ] = React . useState < AutocompleteKind | null >( null );
const [ autocompleteQuery , setAutocompleteQuery ] = React . useState ( '' );
const closeAutocomplete = React . useCallback (() => setOpenAutocomplete ( null ), []);
2026-01-30 06:13:37 -03:00
const [ mobileControlsPanel , setMobileControlsPanel ] = React . useState < MobileControlsPanel >( null );
2026-07-04 16:41:17 +03:00
const [ mobileAttachMenuOpen , setMobileAttachMenuOpen ] = React . useState ( false );
const [ mobileDraftPicker , setMobileDraftPicker ] = React . useState < 'project' | 'branch' | null > ( null );
const [ mobileDraftPickerQuery , setMobileDraftPickerQuery ] = React . useState ( '' );
2026-02-06 01:14:09 -08:00
// Message history navigation state (up/down arrow to recall previous messages)
2026-07-27 22:21:38 +03:00
const composerRef = React . useRef < ComposerEditorHandle >( 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 < HTMLFormElement | null >( null );
2026-04-22 01:31:14 +08:00
const cursorPosRef = React . useRef ( 0 );
2025-12-07 19:32:53 +02:00
const dropZoneRef = React . useRef < HTMLDivElement >( null );
2026-04-22 01:31:14 +08:00
const dragEnterCountRef = React . useRef ( 0 );
2026-03-23 23:51:55 +02:00
const suppressNextFileDropTextInsertRef = React . useRef ( false );
const suppressNextFileDropTextInsertTimeoutRef = React . useRef < ReturnType < typeof setTimeout > | null >( null );
2026-06-23 07:07:09 -04:00
const suppressNextFileMentionPasteRef = React . useRef ( false );
const suppressNextFileMentionPasteTimeoutRef = React . useRef < ReturnType < typeof setTimeout > | null >( null );
2026-03-23 23:51:55 +02:00
const pendingDroppedAbsolutePathsRef = React . useRef < string [] >([]);
2026-02-11 19:28:22 +02:00
const canAcceptDropRef = React . useRef ( false );
2025-12-07 19:32:53 +02:00
const mentionRef = React . useRef < FileMentionHandle >( null );
const commandRef = React . useRef < CommandAutocompleteHandle >( null );
2026-01-08 19:58:31 +02:00
const skillRef = React . useRef < SkillAutocompleteHandle >( null );
2026-05-21 20:00:35 +03:00
const snippetRef = React . useRef < SnippetAutocompleteHandle >( null );
2026-02-23 05:04:25 +07:00
// Ref to track current message value without triggering re-renders in effects
const messageRef = React . useRef ( message );
2026-07-21 20:52:20 +03:00
const currentChatDraftIdentityRef = React . useRef < ChatDraftIdentity | null >( initialDraftIdentityRef . current );
2026-05-24 23:47:22 +03:00
const pendingPastedAttachmentFilenamesRef = React . useRef < Set < string >>( new Set ());
2025-12-07 19:32:53 +02:00
2026-03-31 18:47:00 +03:00
// TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendMessage = React . useRef ((... args : any []) =>
Promise . resolve (( useSessionUIStore . getState (). sendMessage as (... a : unknown []) => unknown )(... args )),
). current ;
const currentSessionId = useSessionUIStore (( s ) => s . currentSessionId );
2026-06-15 15:26:08 +03:00
const fallbackDirectory = useDirectoryStore (( s ) => s . currentDirectory );
const currentDirectory = useEffectiveDirectory () ?? fallbackDirectory ;
2026-05-16 21:44:37 +08:00
const currentSessionDirectoryForSync = useSessionUIStore (
React . useCallback (( s ) => currentSessionId ? s . getDirectoryForSession ( currentSessionId ) : null , [ currentSessionId ]),
);
2026-07-21 20:52:20 +03:00
const activeRuntimeKey = getRuntimeKey ();
const chatDraftIdentity = React . useMemo (
() => createChatDraftIdentity (
activeRuntimeKey ,
currentSessionDirectoryForSync ?? currentDirectory ,
currentSessionId ,
),
[ activeRuntimeKey , currentDirectory , currentSessionDirectoryForSync , currentSessionId ],
);
2026-03-31 18:47:00 +03:00
const newSessionDraft = useSessionUIStore (( s ) => s . newSessionDraft );
2026-03-20 01:01:03 +02:00
const newSessionDraftOpen = Boolean ( newSessionDraft ? . open );
2026-07-11 23:15:02 +11:00
const draftPermissionAutoAcceptEnabled = useSessionUIStore (( s ) => (
s . newSessionDraft ? . open ? s . newSessionDraft . permissionAutoAcceptEnabled === true : false
));
2026-03-31 18:47:00 +03:00
const setNewSessionDraftTarget = useSessionUIStore (( s ) => s . setNewSessionDraftTarget );
2026-07-11 23:15:02 +11:00
const setDraftPermissionAutoAcceptEnabled = useSessionUIStore (( s ) => s . setDraftPermissionAutoAcceptEnabled );
2026-07-04 16:41:17 +03:00
const openNewSessionDraft = useSessionUIStore (( s ) => s . openNewSessionDraft );
2026-03-31 18:47:00 +03:00
const abortPromptSessionId = useSessionUIStore (( s ) => s . abortPromptSessionId );
const clearAbortPrompt = useSessionUIStore (( s ) => s . clearAbortPrompt );
const attachedFiles = useInputStore (( s ) => s . attachedFiles );
const addAttachedFile = useInputStore (( s ) => s . addAttachedFile );
const clearAttachedFiles = useInputStore (( s ) => s . clearAttachedFiles );
const saveSessionAgentSelection = useSelectionStore (( s ) => s . saveSessionAgentSelection );
const consumePendingInputText = useInputStore (( s ) => s . consumePendingInputText );
2026-05-29 13:31:07 +03:00
const pendingPresetSubmit = useInputStore (( s ) => s . pendingPresetSubmit );
2026-03-31 18:47:00 +03:00
const setPendingInputText = useInputStore (( s ) => s . setPendingInputText );
const pendingInputText = useInputStore (( s ) => s . pendingInputText );
const consumePendingSyntheticParts = useInputStore (( s ) => s . consumePendingSyntheticParts );
const acknowledgeSessionAbort = useSessionUIStore (( s ) => s . acknowledgeSessionAbort );
const abortCurrentOperation = React . useCallback (
( sessionIdOverride? : string ) => sessionActions . abortCurrentOperation ( sessionIdOverride ?? currentSessionId ?? '' ),
[ currentSessionId ],
);
const currentManagementSessionId = currentSessionId ;
2026-06-14 16:55:58 +03:00
const [ reviewDialogOpen , setReviewDialogOpen ] = React . useState ( false );
const [ reviewFlowSubmitting , setReviewFlowSubmitting ] = React . useState ( false );
2025-12-07 19:32:53 +02:00
2026-04-04 02:19:55 +03:00
const currentProviderId = useConfigStore (( state ) => state . currentProviderId );
const currentModelId = useConfigStore (( state ) => state . currentModelId );
2026-07-22 14:40:40 +03:00
const getModelMetadata = useConfigStore (( state ) => state . getModelMetadata );
// Subscribe to both sources read by getModelMetadata so async metadata and provider updates are observed.
useConfigStore (( state ) => state . modelsMetadata );
useConfigStore (( state ) => state . providers );
const currentModelMetadata = currentProviderId && currentModelId
? getModelMetadata ( currentProviderId , currentModelId )
: undefined ;
2026-04-04 02:19:55 +03:00
const currentVariant = useConfigStore (( state ) => state . currentVariant );
const currentAgentName = useConfigStore (( state ) => state . currentAgentName );
const setAgent = useConfigStore (( state ) => state . setAgent );
const getVisibleAgents = useConfigStore (( state ) => state . getVisibleAgents );
2025-12-18 19:02:42 +02:00
const agents = getVisibleAgents ();
2026-04-04 02:19:55 +03:00
const isMobile = useUIStore (( state ) => state . isMobile );
2026-08-02 16:25:16 +03:00
const hasHardwareKeyboard = useHardwareKeyboard ();
const { enabled : isTabletLayout } = useTabletLayout ();
2026-05-24 23:18:32 +03:00
const setImagePreviewOpen = useUIStore (( state ) => state . setImagePreviewOpen );
2026-04-04 02:19:55 +03:00
const inputBarOffset = useUIStore (( state ) => state . inputBarOffset );
const persistChatDraft = useUIStore (( state ) => state . persistChatDraft );
const inputSpellcheckEnabled = useUIStore (( state ) => state . inputSpellcheckEnabled );
const isExpandedInput = useUIStore (( state ) => state . isExpandedInput );
const setExpandedInput = useUIStore (( state ) => state . setExpandedInput );
2026-05-05 16:21:06 +08:00
const setTimelineDialogOpen = useUIStore (( state ) => state . setTimelineDialogOpen );
2026-06-02 00:43:05 +03:00
const { git : runtimeGit , vscode : vscodeApi } = useRuntimeAPIs ();
2026-05-15 00:27:39 +03:00
const cycleAgentShortcutOverride = useUIStore (( state ) => state . shortcutOverrides . cycle_agent );
const cycleAgentShortcut = React . useMemo (() => (
getEffectiveShortcutCombo ( 'cycle_agent' , cycleAgentShortcutOverride ? { cycle_agent : cycleAgentShortcutOverride } : undefined )
), [ cycleAgentShortcutOverride ]);
2026-02-01 18:29:34 +02:00
const { currentTheme } = useThemeSystem ();
2026-03-04 01:41:01 +02:00
const chatSearchDirectory = useChatSearchDirectory ();
2026-04-23 10:55:27 +03:00
const isGitRepo = useIsGitRepo ( currentDirectory );
const currentGitStatus = useGitStore (( state ) =>
currentDirectory ? state . directories . get ( currentDirectory ) ? . status ?? null : null ,
);
2026-06-15 15:10:12 +03:00
const ensureGitStatus = useGitStore (( state ) => state . ensureStatus );
const fetchGitStatus = useGitStore (( state ) => state . fetchStatus );
2026-08-02 21:46:22 +03:00
const clearGitDiffCache = useGitStore (( state ) => state . clearDiffCache );
2025-12-07 19:32:53 +02:00
const [ showAbortStatus , setShowAbortStatus ] = React . useState ( false );
2026-03-20 01:01:03 +02:00
const setSessionAutoAccept = usePermissionStore (( state ) => state . setSessionAutoAccept );
2026-05-21 20:00:35 +03:00
const [ isNarrowComposer , setIsNarrowComposer ] = React . useState ( false );
2026-05-24 23:18:32 +03:00
const [ attachmentPreview , setAttachmentPreview ] = React . useState < ToolPopupContent >({
open : false ,
title : '' ,
content : '' ,
});
2026-07-22 14:40:40 +03:00
const attachmentCompatibilityRef = React . useRef ({
modelKey : ` ${ currentProviderId ?? '' } / ${ currentModelId ?? '' } ` ,
modalitySignature : currentModelMetadata?.modalities?.input?.slice (). sort (). join ( ',' ) ?? null ,
attachmentIds : new Set < string >(),
});
React . useEffect (() => {
const modelKey = ` ${ currentProviderId ?? '' } / ${ currentModelId ?? '' } ` ;
const inputModalities = currentModelMetadata ? . modalities ? . input ;
const modalitySignature = inputModalities ? . slice (). sort (). join ( ',' ) ?? null ;
const previous = attachmentCompatibilityRef . current ;
const modelChanged = previous . modelKey !== modelKey ;
const metadataBecameAvailable = previous . modalitySignature === null && modalitySignature !== null ;
const filesToCheck = modelChanged || metadataBecameAvailable
? attachedFiles
: attachedFiles.filter (( file ) => ! previous . attachmentIds . has ( file . id ));
attachmentCompatibilityRef . current = {
modelKey ,
modalitySignature ,
attachmentIds : new Set ( attachedFiles . map (( file ) => file . id )),
};
if ( ! inputModalities || filesToCheck . length === 0 ) return ;
const incompatibleFiles = getUnsupportedAttachmentInputs ( filesToCheck , inputModalities );
if ( incompatibleFiles . length === 0 ) return ;
const unsupportedModalities = Array . from ( new Set ( incompatibleFiles . map (({ modality }) => modality )));
const modalityLabels : Record < AttachmentInputModality , string > = {
text : t ( 'chat.modelControls.modality.text' ),
image : t ( 'chat.modelControls.modality.image' ),
pdf : t ( 'chat.modelControls.modality.pdf' ),
audio : t ( 'chat.modelControls.modality.audio' ),
video : t ( 'chat.modelControls.modality.video' ),
};
const filenames = incompatibleFiles . map (({ attachment }) => attachment . filename );
const fileSummary = filenames . length > 3
? ` ${ filenames . slice ( 0 , 3 ). join ( ', ' ) } (+ ${ filenames . length - 3 } )`
: filenames . join ( ', ' );
toast . warning ( t ( 'chat.chatInput.toast.unsupportedAttachmentModalities' , {
model : currentModelMetadata.name ?? currentModelId ?? '' ,
modalities : unsupportedModalities.map (( modality ) => modalityLabels [ modality ]). join ( ', ' ),
files : fileSummary ,
}), { id : `attachment-modalities: ${ modelKey } ` });
}, [ attachedFiles , currentModelId , currentModelMetadata , currentProviderId , t ]);
2026-05-24 23:18:32 +03:00
const handleShowAttachmentPreview = React . useCallback (( content : ToolPopupContent ) => {
if ( ! content . image ) return ;
setAttachmentPreview ( content );
setImagePreviewOpen ( true );
}, [ setImagePreviewOpen ]);
const handleAttachmentPreviewOpenChange = React . useCallback (( open : boolean ) => {
setAttachmentPreview (( prev ) => ({ ... prev , open }));
setImagePreviewOpen ( open );
}, [ setImagePreviewOpen ]);
2026-03-04 01:41:01 +02:00
2026-06-15 15:10:12 +03:00
React . useEffect (() => {
if ( ! currentDirectory || ! runtimeGit ) return ;
void ensureGitStatus ( currentDirectory , runtimeGit );
}, [ currentDirectory , runtimeGit , ensureGitStatus ]);
React . useEffect (() => {
if ( ! currentDirectory || ! runtimeGit ) return ;
return sessionEvents . onGitRefreshHint (( hint ) => {
if ( normalizePath ( hint . directory ) !== normalizePath ( currentDirectory )) return ;
2026-08-02 21:46:22 +03:00
if ( hint . paths ? . length ) {
clearGitDiffCache ( currentDirectory , hint . paths );
}
void fetchGitStatus ( currentDirectory , runtimeGit , { silent : true });
2026-06-15 15:10:12 +03:00
});
2026-08-02 21:46:22 +03:00
}, [ clearGitDiffCache , currentDirectory , runtimeGit , fetchGitStatus ]);
2026-06-15 15:10:12 +03:00
2026-06-14 16:55:58 +03:00
const handleStartReviewFlow = React . useCallback ( async ( execution : ReviewFlowExecution ) => {
if ( ! currentSessionId ) return ;
const directory = useSessionUIStore . getState (). getDirectoryForSession ( currentSessionId ) || currentDirectory || '' ;
if ( ! directory ) {
toast . error ( t ( 'diffView.reviewDialog.toast.noSessionDirectory' ));
return ;
}
setReviewFlowSubmitting ( true );
try {
await startReviewFlow ({
originalSessionID : currentSessionId ,
directory ,
providerID : execution.providerID ,
modelID : execution.modelID ,
agent : execution.agent || undefined ,
variant : execution.variant || undefined ,
generateHandoff : execution.generateHandoff ,
returnAfterHandoffRequest : execution.generateHandoff ,
2026-06-26 19:29:44 +03:00
autoReview : execution.autoReview ,
2026-06-14 16:55:58 +03:00
});
setReviewDialogOpen ( false );
} catch ( error ) {
console . error ( '[review-flow] failed to start review flow' , error );
toast . error ( error instanceof Error ? error.message : t ( 'diffView.reviewDialog.toast.startFailed' ));
} finally {
setReviewFlowSubmitting ( false );
}
}, [ currentSessionId , currentDirectory , t ]);
2026-02-23 05:04:25 +07:00
const isDesktopExpanded = isExpandedInput && ! isMobile ;
2026-07-04 16:41:17 +03:00
// Mobile fullscreen composer (entered via the drag handle's swipe-up).
const isMobileExpanded = isExpandedInput && isMobile ;
const isComposerExpanded = isDesktopExpanded || isMobileExpanded ;
2026-07-01 09:55:41 +03:00
// Rounder composer on mobile (touch UI reads better with a softer corner).
const chatInputRadius = isMobile ? '1.5rem' : 'var(--radius-xl)' ;
2026-05-21 20:00:35 +03:00
const useCompactChatPlaceholder = isMobile || isNarrowComposer ;
React . useEffect (() => {
const element = dropZoneRef . current ;
if ( ! element ) return ;
const updateWidth = ( width : number ) => {
const next = width > 0 && width < COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH ;
setIsNarrowComposer (( prev ) => ( prev === next ? prev : next ));
};
updateWidth ( element . clientWidth );
if ( typeof ResizeObserver === 'undefined' ) {
const handleResize = () => updateWidth ( element . clientWidth );
window . addEventListener ( 'resize' , handleResize );
return () => window . removeEventListener ( 'resize' , handleResize );
}
const observer = new ResizeObserver (( entries ) => {
updateWidth ( entries [ 0 ] ? . contentRect . width ?? element . clientWidth );
});
observer . observe ( element );
return () => observer . disconnect ();
}, []);
2026-03-04 01:41:01 +02:00
2026-04-26 16:24:07 +03:00
const knownAgentNames = React . useMemo (
() => new Set ( agents . map (( agent ) => agent . name . toLowerCase ())),
[ agents ]
);
const knownAgentNamesRef = React . useRef ( knownAgentNames );
knownAgentNamesRef . current = knownAgentNames ;
2026-05-24 17:21:11 +03:00
// Known slash-invocations (commands + skills + built-ins) used to highlight
// matching /tokens in the composer, the same way confirmed @files are.
const availableCommands = useCommandsStore (( s ) => s . commands );
const availableSkills = useSkillsStore (( s ) => s . skills );
const knownSlashNames = React . useMemo (() => {
const names = new Set < string >([
2026-07-24 21:54:28 +03:00
'init' , 'review' , 'undo' , 'redo' , 'timeline' , 'compact' , 'summary' , 'workspace-review' , 'plan-feature' , 'craft-goal' , 'schedule-task' , 'catch-up' , 'debug' , 'weigh' , 'explore' ,
2026-05-24 17:21:11 +03:00
]);
2026-06-07 01:22:40 +03:00
if ( ! isMobile && ! isVSCodeRuntime ()) names . add ( 'handoff-review' );
2026-05-24 17:21:11 +03:00
for ( const command of availableCommands ) names . add ( command . name . toLowerCase ());
for ( const skill of availableSkills ) names . add ( skill . name . toLowerCase ());
return names ;
2026-06-07 01:22:40 +03:00
}, [ availableCommands , availableSkills , isMobile ]);
2026-05-24 17:21:11 +03:00
const availableSnippets = useSnippetsStore (( s ) => s . snippets );
const knownSnippetTriggers = React . useMemo (() => {
const triggers = new Set < string >();
for ( const snippet of availableSnippets ) {
triggers . add ( snippet . name . toLowerCase ());
for ( const alias of snippet . aliases ?? []) triggers . add ( alias . toLowerCase ());
}
return triggers ;
}, [ availableSnippets ]);
2026-07-27 22:21:38 +03:00
const attachmentFilenames = React . useMemo (
() => attachedFiles . map (( file ) => file . filename ),
[ attachedFiles ],
);
2026-05-24 23:47:22 +03:00
2026-07-27 22:21:38 +03:00
/**
* 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 < ComposerLanguageContext >(() => ({
inputMode ,
knownAgentNames ,
confirmedMentions : confirmedMentionsRef.current ,
knownSlashNames ,
knownSnippetTriggers ,
attachmentFilenames ,
}), [ attachmentFilenames , inputMode , knownAgentNames , knownSlashNames , knownSnippetTriggers ]);
2026-03-04 01:41:01 +02:00
const sanitizeAttachmentsForSend = React . useCallback (
2026-07-27 22:21:38 +03:00
( files : readonly AttachedFile [] | undefined ) : AttachedFile [] => [...( files ?? [])]
2026-03-31 18:47:00 +03:00
. map (( file ) => ({
... file ,
dataUrl : file.source === 'server' && file . serverPath
? toServerFileUrl ( file . serverPath )
: file . dataUrl ,
})),
2026-03-04 01:41:01 +02:00
[],
);
const extractInlineFileMentions = React . useCallback (( rawText : string ) : { sanitizedText : string ; attachments : AttachedFile [] } => {
if ( ! rawText || ! rawText . includes ( '@' )) {
return { sanitizedText : rawText , attachments : [] };
}
const clientDirectory = opencodeClient . getDirectory () || '' ;
const root = ( chatSearchDirectory || clientDirectory ). replace ( /\\/g , '/' ). replace ( /\/+$/ , '' );
const seenPaths = new Set < string >();
const attachments : AttachedFile [] = [];
2026-07-27 22:21:38 +03:00
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' ) {
2026-03-04 01:41:01 +02:00
continue ;
}
const normalizedMentionPath = mentionPath . replace ( /\\/g , '/' ). replace ( /^\.\// , '' ). replace ( /^\/+/ , '' );
if ( ! normalizedMentionPath ) {
continue ;
}
const serverPath = mentionPath . startsWith ( '/' )
? mentionPath . replace ( /\\/g , '/' )
: root
? ` ${ root } / ${ normalizedMentionPath } `
: null ;
if ( ! serverPath ) {
continue ;
}
const normalizedServerPath = serverPath . replace ( /\/+/g , '/' );
if ( seenPaths . has ( normalizedServerPath )) {
continue ;
}
seenPaths . add ( normalizedServerPath );
const filename = normalizedMentionPath . split ( '/' ). filter ( Boolean ). pop () || normalizedMentionPath ;
attachments . push ({
id : `inline-server- ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 , 9 ) } ` ,
file : new File ([], filename , { type : 'text/plain' }),
filename ,
mimeType : 'text/plain' ,
size : 0 ,
2026-03-31 18:47:00 +03:00
dataUrl : toServerFileUrl ( normalizedServerPath ),
2026-03-04 01:41:01 +02:00
source : 'server' ,
serverPath : normalizedServerPath ,
});
}
return {
sanitizedText : rawText ,
attachments ,
};
2026-04-26 16:24:07 +03:00
}, [ chatSearchDirectory ]);
2025-12-07 19:32:53 +02:00
const abortTimeoutRef = React . useRef < ReturnType < typeof setTimeout > | null >( null );
const prevWasAbortedRef = React . useRef ( false );
2025-12-18 11:20:37 +02:00
2026-03-04 01:41:01 +02:00
// Issue linking state
2026-03-03 00:20:15 +02:00
const [ issuePickerOpen , setIssuePickerOpen ] = React . useState ( false );
2026-03-04 01:41:01 +02:00
const [ prPickerOpen , setPrPickerOpen ] = React . useState ( false );
2026-03-03 00:20:15 +02:00
const [ linkedIssue , setLinkedIssue ] = React . useState < {
number : number ;
title : string ;
url : string ;
contextText : string ;
author ?: { login : string ; avatarUrl? : string };
} | null > ( null );
2026-03-04 01:41:01 +02:00
const [ linkedPr , setLinkedPr ] = React . useState < {
number : number ;
title : string ;
url : string ;
head : string ;
base : string ;
includeDiff : boolean ;
instructionsText : string ;
contextText : string ;
author ?: { login : string ; avatarUrl? : string };
} | null > ( null );
2026-03-03 00:20:15 +02:00
2025-12-29 02:16:42 +02:00
// Message queue
2026-07-21 20:52:20 +03:00
const messageQueueTarget = currentSessionId
? createMessageQueueTarget ( currentSessionId , currentSessionDirectoryForSync ?? currentDirectory )
: null ;
const messageQueueKey = messageQueueTarget ? getMessageQueueKey ( messageQueueTarget ) : null ;
2026-06-29 09:28:20 +11:00
const followUpBehavior = useMessageQueueStore (( state ) => state . followUpBehavior );
2025-12-29 02:16:42 +02:00
const queuedMessages = useMessageQueueStore (
React . useCallback (
( state ) => {
2026-07-21 20:52:20 +03:00
if ( ! messageQueueKey ) return EMPTY_QUEUE ;
return state . queuedMessages [ messageQueueKey ] ?? EMPTY_QUEUE ;
2025-12-29 02:16:42 +02:00
},
2026-07-21 20:52:20 +03:00
[ messageQueueKey ]
2025-12-29 02:16:42 +02:00
)
);
const addToQueue = useMessageQueueStore (( state ) => state . addToQueue );
const clearQueue = useMessageQueueStore (( state ) => state . clearQueue );
2026-05-27 17:13:45 +03:00
const removeFromQueue = useMessageQueueStore (( state ) => state . removeFromQueue );
2025-12-29 02:16:42 +02:00
2026-02-05 03:14:26 +02:00
// Inline comment drafts
2026-07-21 20:52:20 +03:00
const inlineDraftSessionKey = currentSessionId ?? ( newSessionDraftOpen ? 'draft' : '' );
const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory ;
const inlineDraftTarget = React . useMemo < InlineCommentDraftTarget | null >(
() => inlineDraftSessionKey && inlineDraftDirectory
? { directory : inlineDraftDirectory , sessionKey : inlineDraftSessionKey }
: null ,
[ inlineDraftDirectory , inlineDraftSessionKey ],
);
const inlineDraftKey = inlineDraftTarget
? getInlineCommentDraftKey ( activeRuntimeKey , inlineDraftTarget . directory , inlineDraftTarget . sessionKey )
: null ;
2026-02-05 03:14:26 +02:00
const draftCount = useInlineCommentDraftStore (
React . useCallback (
2026-07-21 20:52:20 +03:00
( state ) => inlineDraftKey ? ( state . drafts [ inlineDraftKey ] ?? []). length : 0 ,
[ inlineDraftKey ]
2026-02-05 03:14:26 +02:00
)
);
2026-04-29 17:03:38 -04:00
const draftSourceKey = useInlineCommentDraftStore (
React . useCallback (
( state ) => {
2026-07-21 20:52:20 +03:00
const drafts = inlineDraftKey ? ( state . drafts [ inlineDraftKey ] ?? []) : [];
2026-04-29 17:03:38 -04:00
let previewConsole = 0 ;
let previewAnnotation = 0 ;
let review = 0 ;
2026-07-17 13:17:21 +03:00
let terminal = 0 ;
2026-07-27 23:07:42 +03:00
let prComment = 0 ;
let prCheck = 0 ;
2026-04-29 17:03:38 -04:00
for ( const draft of drafts ) {
if ( draft . source === 'preview-console' ) previewConsole += 1 ;
else if ( draft . source === 'preview-annotation' ) previewAnnotation += 1 ;
2026-07-17 13:17:21 +03:00
else if ( draft . source === 'terminal' ) terminal += 1 ;
2026-07-27 23:07:42 +03:00
else if ( draft . source === 'pr-comment' ) prComment += 1 ;
else if ( draft . source === 'pr-check' ) prCheck += 1 ;
2026-04-29 17:03:38 -04:00
else review += 1 ;
}
2026-07-27 23:07:42 +03:00
return ` ${ previewConsole } : ${ previewAnnotation } : ${ review } : ${ terminal } : ${ prComment } : ${ prCheck } ` ;
2026-04-29 17:03:38 -04:00
},
2026-07-21 20:52:20 +03:00
[ inlineDraftKey ]
2026-04-29 17:03:38 -04:00
)
);
2026-02-05 03:14:26 +02:00
const consumeDrafts = useInlineCommentDraftStore (( state ) => state . consumeDrafts );
2026-04-29 17:03:38 -04:00
const removeInlineCommentDraft = useInlineCommentDraftStore (( state ) => state . removeDraft );
2026-02-05 03:14:26 +02:00
const hasDrafts = draftCount > 0 ;
2026-07-27 23:07:42 +03:00
const [ previewConsoleCount , previewAnnotationCount , reviewCount , terminalContextCount , prCommentCount , prCheckCount ] = draftSourceKey . split ( ':' ). map (( entry ) => Number ( entry ) || 0 );
2026-07-17 13:17:21 +03:00
const terminalContextDrafts = terminalContextCount > 0
2026-07-21 20:52:20 +03:00
? ( inlineDraftKey ? useInlineCommentDraftStore . getState (). drafts [ inlineDraftKey ] ?? [] : []). filter (( draft ) => draft . source === 'terminal' )
2026-07-17 13:17:21 +03:00
: [];
2026-07-27 23:07:42 +03:00
const removePreviewDrafts = React . useCallback (( source : 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check' ) => {
2026-07-21 20:52:20 +03:00
if ( ! inlineDraftTarget ) return ;
const drafts = useInlineCommentDraftStore . getState (). getDrafts ( inlineDraftTarget );
2026-04-29 17:03:38 -04:00
for ( const draft of drafts ) {
if ( draft . source === source ) {
2026-07-21 20:52:20 +03:00
removeInlineCommentDraft ( inlineDraftTarget , draft . id );
2026-04-29 17:03:38 -04:00
}
}
2026-07-21 20:52:20 +03:00
}, [ inlineDraftTarget , removeInlineCommentDraft ]);
2026-06-02 20:50:08 +03:00
// Review comments are the inline-comment drafts that aren't preview sources.
const removeReviewDrafts = React . useCallback (() => {
2026-07-21 20:52:20 +03:00
if ( ! inlineDraftTarget ) return ;
const drafts = useInlineCommentDraftStore . getState (). getDrafts ( inlineDraftTarget );
2026-06-02 20:50:08 +03:00
for ( const draft of drafts ) {
2026-07-27 23:07:42 +03:00
if ( draft . source !== 'preview-console' && draft . source !== 'preview-annotation' && draft . source !== 'terminal' && draft . source !== 'pr-comment' && draft . source !== 'pr-check' ) {
2026-07-21 20:52:20 +03:00
removeInlineCommentDraft ( inlineDraftTarget , draft . id );
2026-06-02 20:50:08 +03:00
}
}
2026-07-21 20:52:20 +03:00
}, [ inlineDraftTarget , removeInlineCommentDraft ]);
2026-02-05 03:14:26 +02:00
2026-04-05 15:36:11 +03:00
// User message history for up/down arrow navigation.
// Keep this on a narrow hook instead of full session message records.
2026-07-27 22:21:38 +03:00
const messageHistory = useMessageHistory ( useUserMessageHistory ( currentSessionId ?? "" ));
2026-02-06 01:14:09 -08:00
2026-02-23 05:04:25 +07:00
// Keep messageRef in sync with message state
React . useEffect (() => {
messageRef . current = message ;
}, [ message ]);
2026-03-20 01:01:03 +02:00
React . useEffect (() => {
2026-07-21 20:52:20 +03:00
currentChatDraftIdentityRef . current = chatDraftIdentity ;
}, [ chatDraftIdentity ]);
2026-03-20 01:01:03 +02:00
2026-07-27 22:21:38 +03:00
// 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 (),
});
2026-02-06 01:14:09 -08:00
2026-02-11 10:08:15 -08:00
// Focus textarea when new session draft is opened
const prevNewSessionDraftOpenRef = React . useRef ( newSessionDraftOpen );
React . useEffect (() => {
if ( ! prevNewSessionDraftOpenRef . current && newSessionDraftOpen ) {
// New session draft just opened - focus the textarea
requestAnimationFrame (() => {
if ( isMobile ) {
// On mobile, use preventScroll to avoid viewport jumping
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ({ preventScroll : true });
2026-02-11 10:08:15 -08:00
} else {
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2026-02-11 10:08:15 -08:00
}
});
}
prevNewSessionDraftOpenRef . current = newSessionDraftOpen ;
}, [ newSessionDraftOpen , isMobile ]);
2026-02-26 00:29:13 +02:00
// Session activity for queue availability and controls
2025-12-29 02:16:42 +02:00
const { phase : sessionPhase } = useCurrentSessionActivity ();
2026-06-26 19:29:44 +03:00
const autoReviewRunning = useAutoReviewStore ( React . useCallback (( state ) => {
if ( ! currentSessionId ) return false ;
const run = state . runsByOriginalSessionID [ currentSessionId ];
return run ? . status === 'running' && run . runtimeKey === getRuntimeKey ();
}, [ currentSessionId ]));
2025-12-29 02:16:42 +02:00
2026-01-30 06:13:37 -03:00
const handleOpenMobilePanel = React . useCallback (( panel : MobileControlsPanel ) => {
if ( ! isMobile ) {
return ;
}
2026-07-04 16:41:17 +03:00
// Set the panel state BEFORE blurring: the collapse watcher and the
// overlay-host observer must already see the overlay as open when the
// keyboard-close lands, otherwise the composer folds into the pill
// under the sheet.
setMobileControlsPanel ( panel );
2026-07-27 22:21:38 +03:00
composerRef . current ? . blur ();
2026-01-30 06:13:37 -03:00
}, [ isMobile ]);
2025-12-21 00:47:43 +02:00
// Consume pending input text (e.g., from revert action)
React . useEffect (() => {
if ( pendingInputText !== null ) {
2026-02-05 03:14:26 +02:00
const pending = consumePendingInputText ();
if ( pending ? . text ) {
if ( pending . mode === 'append' ) {
setMessage (( prev ) => {
2026-03-17 13:18:54 +02:00
const next = pending . text ;
if ( ! next . trim ()) return prev ;
return appendWithLineBreaks ( prev , next );
2026-02-05 03:14:26 +02:00
});
2026-03-23 23:51:55 +02:00
} else if ( pending . mode === 'append-inline' ) {
setMessage (( prev ) => appendInlineText ( prev , pending . text ));
2026-02-05 03:14:26 +02:00
} else {
setMessage ( pending . text );
}
2025-12-21 00:47:43 +02:00
// Focus textarea after setting message
setTimeout (() => {
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2025-12-21 00:47:43 +02:00
}, 0 );
}
}
}, [ pendingInputText , consumePendingInputText ]);
2026-07-22 11:54:03 +03:00
const hasContent = message . trim (). length > 0 || attachedFiles . length > 0 || hasDrafts ;
2025-12-29 02:16:42 +02:00
const hasQueuedMessages = queuedMessages . length > 0 ;
const canSend = hasContent || hasQueuedMessages ;
2025-12-07 19:32:53 +02:00
2026-04-05 15:36:11 +03:00
const canAbort = sessionPhase !== 'idle' ;
2025-12-07 19:32:53 +02:00
2026-05-01 04:26:14 -07:00
const getCurrentInputSnapshot = React . useCallback (() => {
2026-07-27 22:21:38 +03:00
const currentMessage = composerRef . current ? . getValue () ?? message ;
2026-05-01 04:26:14 -07:00
return {
message : currentMessage ,
2026-07-22 11:54:03 +03:00
hasContent : currentMessage.trim (). length > 0 || attachedFiles . length > 0 || hasDrafts ,
2026-05-01 04:26:14 -07:00
};
2026-07-22 11:54:03 +03:00
}, [ attachedFiles . length , hasDrafts , message ]);
2026-05-01 04:26:14 -07:00
2026-02-05 01:59:49 +02:00
// Keep a ref to handleSubmit so callbacks don't depend on it.
2026-02-11 19:28:22 +02:00
type SubmitOptions = {
queuedOnly? : boolean ;
2026-05-27 17:13:45 +03:00
queuedMessageId? : string ;
2026-06-29 09:28:20 +11:00
delivery ?: 'steer' ;
2026-07-05 02:32:40 +03:00
/** Submit this text instead of the composer input. Used by preset
starter chips: on mobile the collapsed pill has no mounted textarea,
so the DOM-first input snapshot would read empty content. */
presetText? : string ;
2026-02-11 19:28:22 +02:00
};
const handleSubmitRef = React . useRef < ( options? : SubmitOptions ) => Promise < void > > ( async () => {});
2026-02-05 01:59:49 +02:00
2025-12-29 02:16:42 +02:00
// Add message to queue instead of sending
const handleQueueMessage = React . useCallback (() => {
2026-05-01 04:26:14 -07:00
const inputSnapshot = getCurrentInputSnapshot ();
2026-07-21 20:52:20 +03:00
if ( ! inputSnapshot . hasContent || ! currentSessionId || ! messageQueueTarget ) return ;
2025-12-07 19:32:53 +02:00
2026-07-21 20:52:20 +03:00
const drafts = inlineDraftTarget ? consumeDrafts ( inlineDraftTarget ) : [];
2026-02-05 03:14:26 +02:00
2026-05-01 04:26:14 -07:00
let messageToQueue = inputSnapshot . message . replace ( /^\n+|\n+$/g , '' );
2026-02-05 03:14:26 +02:00
if ( drafts . length > 0 ) {
messageToQueue = appendInlineComments ( messageToQueue , drafts );
}
2026-07-22 11:54:03 +03:00
const attachmentsToQueue = sanitizeAttachmentsForSend ( attachedFiles );
2025-12-07 19:32:53 +02:00
2026-07-21 20:52:20 +03:00
addToQueue ( messageQueueTarget , {
2025-12-29 02:16:42 +02:00
content : messageToQueue ,
attachments : attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined ,
2026-03-31 18:47:00 +03:00
sendConfig : currentProviderId && currentModelId ? {
providerID : currentProviderId ,
modelID : currentModelId ,
agent : currentAgentName ?? undefined ,
variant : currentVariant ?? undefined ,
} : undefined ,
2025-12-29 02:16:42 +02:00
});
2025-12-07 19:32:53 +02:00
2025-12-29 02:16:42 +02:00
// Clear input and attachments
2026-04-22 01:31:14 +08:00
// Note: confirmedMentionsRef is NOT cleared here because queued messages
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
// The ref is cleared in handleSubmit after all queued messages are sent.
2025-12-29 02:16:42 +02:00
setMessage ( '' );
if ( attachmentsToQueue . length > 0 ) {
clearAttachedFiles ();
}
2025-12-07 19:32:53 +02:00
2025-12-29 02:16:42 +02:00
if ( ! isMobile ) {
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2025-12-07 19:32:53 +02:00
}
2026-07-22 11:54:03 +03:00
}, [ getCurrentInputSnapshot , currentSessionId , messageQueueTarget , inlineDraftTarget , attachedFiles , sanitizeAttachmentsForSend , addToQueue , clearAttachedFiles , isMobile , consumeDrafts , currentProviderId , currentModelId , currentAgentName , currentVariant ]);
2025-12-07 19:32:53 +02:00
2026-04-04 02:19:55 +03:00
const handleQueuedMessageEdit = React . useCallback (( content : string ) => {
setMessage ( content );
setTimeout (() => {
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2026-04-04 02:19:55 +03:00
}, 0 );
}, []);
2026-05-27 17:13:45 +03:00
const handleQueuedMessageSend = React . useCallback (( messageId : string ) => {
2026-06-29 09:28:20 +11:00
// Force-sending from the queue during a busy session counts as steer
void handleSubmitRef . current ({ queuedOnly : true , queuedMessageId : messageId , delivery : 'steer' });
2026-05-27 17:13:45 +03:00
}, []);
2026-04-04 02:19:55 +03:00
const handleOpenAgentPanel = React . useCallback (() => {
setMobileControlsPanel ( 'agent' );
}, []);
const handleToggleExpandedInput = React . useCallback (() => {
setExpandedInput ( ! isExpandedInput );
}, [ isExpandedInput , setExpandedInput ]);
const openIssuePicker = React . useCallback (() => {
setIssuePickerOpen ( true );
}, []);
const openPrPicker = React . useCallback (() => {
setPrPickerOpen ( true );
}, []);
2026-08-07 05:46:21 +08:00
const getSubmitErrorMessage = ( error : unknown , fallback : string ) => {
const message = error instanceof Error ? error . message : '' ;
return message . toLowerCase (). includes ( 'runtime changed' )
? t ( 'chat.chatInput.toast.messageSendFailed' )
: message || fallback ;
};
2026-02-11 19:28:22 +02:00
const handleSubmit = async ( options? : SubmitOptions ) => {
const queuedOnly = options ? . queuedOnly ?? false ;
2026-05-27 17:13:45 +03:00
const queuedMessageId = options ? . queuedMessageId ;
2026-06-29 09:28:20 +11:00
const delivery = options ? . delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined ;
2026-08-07 05:46:21 +08:00
const capturedTarget = messageQueueTarget ;
2026-07-05 02:32:40 +03:00
const inputSnapshot = options ? . presetText != null
? {
message : options.presetText ,
2026-07-22 11:54:03 +03:00
hasContent : options.presetText.trim (). length > 0 || attachedFiles . length > 0 || hasDrafts ,
2026-07-05 02:32:40 +03:00
}
: getCurrentInputSnapshot ();
2026-08-03 23:14:14 +03:00
// A queued item stays in the queue until its own send resolves, so the
// auto-send hook may already be delivering one of these. Merging it here
// would send the same message twice (the window is seconds over a relay).
const sendingIds = messageQueueTarget
? useMessageQueueStore . getState (). sendingIds [ getMessageQueueKey ( messageQueueTarget )] ?? EMPTY_SENDING_IDS
: EMPTY_SENDING_IDS ;
const queuedMessagesToSend = ( queuedMessageId
2026-05-27 17:13:45 +03:00
? queuedMessages . filter (( message ) => message . id === queuedMessageId )
2026-08-03 23:14:14 +03:00
: queuedMessages
). filter (( message ) => ! sendingIds . includes ( message . id ));
2025-12-29 02:16:42 +02:00
2026-06-26 19:29:44 +03:00
if ( queuedOnly && autoReviewRunning ) {
return ;
}
2026-02-11 19:28:22 +02:00
if ( queuedOnly ) {
2026-05-27 17:13:45 +03:00
if ( queuedMessagesToSend . length === 0 || ! currentSessionId ) return ;
2026-05-01 04:26:14 -07:00
} else if (( ! inputSnapshot . hasContent && ! hasQueuedMessages ) || ( ! currentSessionId && ! newSessionDraftOpen )) {
2026-02-11 19:28:22 +02:00
return ;
}
2025-12-07 19:32:53 +02:00
2026-05-27 17:13:45 +03:00
const capturedSendConfig = queuedOnly ? queuedMessagesToSend [ 0 ] ? . sendConfig : undefined ;
const providerIdToSend = capturedSendConfig ? . providerID ?? currentProviderId ;
const modelIdToSend = capturedSendConfig ? . modelID ?? currentModelId ;
const agentNameToSend = capturedSendConfig ? . agent ?? currentAgentName ;
const variantToSend = capturedSendConfig ? . variant ?? currentVariant ;
if ( ! providerIdToSend || ! modelIdToSend ) {
2025-12-07 19:32:53 +02:00
console . warn ( 'Cannot send message: provider or model not selected' );
return ;
}
2026-06-25 03:55:35 -04:00
// Sending is authoritative: if a question prompt is open, dismiss it
// so the prompt cannot linger or strand the session. The dismiss clears
// the card instantly (optimistic) and formally rejects the question.
// Rejecting unblocks the agent's tool but does NOT end its turn, so a
// direct send would race with the still-active run and be silently
// discarded by the OpenCode runner. Instead we queue the message; the
// queued-message auto-send hook delivers it as the next turn once the
// rejected turn winds down and the session returns to idle. This avoids
// aborting the turn (which would surface an "aborted" notice).
2026-06-26 19:29:44 +03:00
if ( currentSessionId && ! queuedOnly && autoReviewRunning ) {
handleQueueMessage ();
return ;
}
2026-06-25 03:55:35 -04:00
if ( currentSessionId && ! queuedOnly ) {
2026-07-28 10:36:00 -04:00
// Sending is authoritative for blocking prompts: deny pending
// permissions and dismiss open questions for the session subtree,
// then queue the message once if either was open. The deny/clear
// vanishes the card instantly (optimistic); rejecting unblocks the
// agent's tool but does NOT end its turn, so a direct send would
// race with the still-active run and be silently discarded by the
// OpenCode runner. Instead we queue; the queued-message auto-send
// hook delivers it as the next turn once the rejected turn winds
// down and the session returns to idle (parity with #1740).
const [ deniedPermissions , dismissedQuestions ] = await Promise . all ([
sessionActions . dismissOpenPermissionsForSession ( currentSessionId ),
sessionActions . dismissOpenQuestionsForSession ( currentSessionId ),
]);
if ( deniedPermissions || dismissedQuestions ) {
2026-06-25 03:55:35 -04:00
handleQueueMessage ();
return ;
}
}
2026-08-07 05:46:21 +08:00
const sendMessageOptions = capturedTarget
? { target : capturedTarget , ...( delivery ? { delivery } : {}) }
: delivery ? { delivery } : undefined ;
2026-06-29 09:28:20 +11:00
2026-07-27 22:21:38 +03:00
// Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took.
2026-02-07 01:33:17 -08:00
const syntheticParts = consumePendingSyntheticParts ();
2026-07-27 22:21:38 +03:00
const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget ;
const drafts : InlineCommentDraft [] = consumedDraftTarget
? consumeDrafts ( consumedDraftTarget )
: [];
2025-12-29 02:16:42 +02:00
2026-07-27 22:21:38 +03:00
const availableSkillNames = new Set (
useSkillsStore . getState (). skills . map (( skill ) => skill . name ),
);
2025-12-07 19:32:53 +02:00
2026-07-27 22:21:38 +03:00
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 ,
});
2025-12-18 11:20:37 +02:00
2026-07-27 22:21:38 +03:00
let primaryText = outgoing . primaryText ;
const { primaryAttachments , additionalParts , agentMentionName } = outgoing ;
2026-01-02 18:33:35 -05:00
2026-07-27 22:21:38 +03:00
if ( outgoing . isEmpty ) return ;
// Clear queue and input
2026-08-07 05:46:21 +08:00
if ( capturedTarget && queuedMessageId ) {
removeFromQueue ( capturedTarget , queuedMessageId );
} else if ( capturedTarget && hasQueuedMessages ) {
clearQueue ( capturedTarget );
2026-07-27 22:21:38 +03:00
}
if ( ! queuedOnly ) {
setMessage ( '' );
confirmedMentionsRef . current . clear ();
// Clear per-session draft on submit
persistDraftImmediately ( chatDraftIdentity , '' );
messageHistory . reset ();
if ( attachedFiles . length > 0 ) {
clearAttachedFiles ();
}
// Close expanded input overlay when submitting
setExpandedInput ( false );
}
if ( isMobile ) {
composerRef . current ? . blur ();
}
// 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 ? .();
2026-07-12 10:58:58 +03:00
return ;
}
2026-07-27 22:21:38 +03:00
if ( commandName === 'redo' && currentSessionId ) {
await useSessionUIStore . getState (). handleSlashRedo ( currentSessionId );
scrollToBottom ? .();
2026-07-24 21:54:28 +03:00
return ;
}
2026-07-27 22:21:38 +03:00
if ( commandName === 'timeline' && currentSessionId ) {
setTimelineDialogOpen ( true );
2026-05-29 02:56:44 +03:00
return ;
}
2026-07-27 22:21:38 +03:00
if ( commandName === 'handoff-review' && currentSessionId && ! isMobile && ! isVSCodeRuntime ()) {
setReviewDialogOpen ( true );
2026-05-29 02:56:44 +03:00
return ;
}
2026-07-27 22:21:38 +03:00
if ( commandName === 'compact' && currentSessionId ) {
2026-05-29 03:12:52 +03:00
try {
await sessionActions . waitForConnectionOrThrow ();
2026-07-27 22:21:38 +03:00
const compactDirectory = useSessionUIStore . getState (). getDirectoryForSession ( currentSessionId ) || currentDirectory || undefined ;
await opencodeClient . summarizeSession ( currentSessionId , currentProviderId , currentModelId , compactDirectory );
2026-05-29 03:12:52 +03:00
} catch ( error ) {
2026-08-07 05:46:21 +08:00
toast . error ( getSubmitErrorMessage ( error , t ( 'chat.chatInput.toast.compactFailed' )));
2026-05-29 03:12:52 +03:00
}
return ;
}
2026-07-27 22:21:38 +03:00
// 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 );
2026-05-29 12:08:01 +03:00
try {
await sessionActions . waitForConnectionOrThrow ();
2026-07-27 22:21:38 +03:00
const visibleText = await renderMagicPrompt ( command . visiblePrompt , variables . visible );
const instructionsText = await renderMagicPrompt ( command . instructionsPrompt , variables . instructions );
2026-05-29 12:08:01 +03:00
await sendMessage (
visibleText ,
providerIdToSend ,
modelIdToSend ,
agentNameToSend ,
[],
agentMentionName ,
[{ text : instructionsText , synthetic : true }],
variantToSend ,
inputMode ,
2026-06-29 09:28:20 +11:00
sendMessageOptions ,
2026-05-29 12:08:01 +03:00
);
scrollToBottom ? .();
} catch ( error ) {
2026-08-07 05:46:21 +08:00
toast . error ( getSubmitErrorMessage ( error , t ( command . errorToastKey )));
2026-05-29 12:08:01 +03:00
}
return ;
}
2025-12-29 02:16:42 +02:00
}
2026-08-07 05:46:21 +08:00
const currentSessionDirectory = capturedTarget ? . directory ?? currentDirectory ;
2026-05-01 01:27:31 +03:00
const shouldAddResponseStyle = newSessionDraftOpen || ( currentSessionId ? ! hasUserMessages ( currentSessionId , currentSessionDirectory ) : false );
if ( shouldAddResponseStyle ) {
const responseStyleInstruction = await fetchResponseStyleInstruction (). catch (() => null );
if ( responseStyleInstruction ) {
additionalParts . push ({
2026-05-07 19:35:46 +03:00
text : wrapSystemReminder ( responseStyleInstruction ),
2026-05-01 01:27:31 +03:00
synthetic : true ,
});
}
}
2026-05-21 20:00:35 +03:00
try {
const expandText = useSnippetsStore . getState (). expandText ;
primaryText = await expandText ( primaryText );
for ( const part of additionalParts ) {
if ( ! part . synthetic ) part . text = await expandText ( part . text );
}
} catch ( error ) {
console . warn ( '[ChatInput] Failed to expand snippets, sending original text:' , error );
}
2025-12-29 02:16:42 +02:00
// Collect all attachments for error recovery
const allAttachments = [
... primaryAttachments ,
... additionalParts . flatMap ( p => p . attachments ?? []),
];
2026-04-06 20:18:20 +03:00
const sendPromise = sendMessage (
2026-01-24 21:11:50 +02:00
primaryText ,
2026-05-27 17:13:45 +03:00
providerIdToSend ,
modelIdToSend ,
agentNameToSend ,
2026-01-24 21:11:50 +02:00
primaryAttachments ,
2025-12-29 02:16:42 +02:00
agentMentionName ,
2026-01-08 14:48:06 +02:00
additionalParts . length > 0 ? additionalParts : undefined ,
2026-05-27 17:13:45 +03:00
variantToSend ,
2026-06-29 09:28:20 +11:00
inputMode ,
sendMessageOptions ,
2026-04-06 20:18:20 +03:00
);
2026-07-17 13:17:21 +03:00
const restoreConsumedDrafts = () => {
2026-07-21 20:52:20 +03:00
if ( consumedDraftTarget && drafts . length > 0 ) {
useInlineCommentDraftStore . getState (). restoreDrafts ( consumedDraftTarget , drafts );
2026-07-17 13:17:21 +03:00
}
};
2026-04-06 20:18:20 +03:00
if ( typeof window === 'undefined' ) {
2026-05-08 14:20:16 +03:00
scrollToBottom ? .();
2026-04-06 20:18:20 +03:00
} else {
window . requestAnimationFrame (() => {
2026-05-08 14:20:16 +03:00
scrollToBottom ? .();
2026-04-06 20:18:20 +03:00
});
}
void sendPromise . then (() => {
2026-03-04 01:41:01 +02:00
// Clear linked issue after successful message send
if ( linkedIssue ) {
2026-03-03 00:20:15 +02:00
setLinkedIssue ( null );
}
2026-03-04 01:41:01 +02:00
if ( linkedPr ) {
setLinkedPr ( null );
}
2026-03-03 00:20:15 +02:00
}). catch (( error : unknown ) => {
2026-02-04 01:14:10 -08:00
const rawMessage =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: String ( error ?? '' );
const normalized = rawMessage . toLowerCase ();
console . error ( 'Message send failed:' , rawMessage || error );
2026-07-17 13:17:21 +03:00
restoreConsumedDrafts ();
2026-02-04 01:14:10 -08:00
2026-07-27 22:21:38 +03:00
const currentInput = composerRef . current ? . getValue () ?? messageRef . current ;
2026-07-17 10:31:56 +03:00
if ( newSessionDraftOpen && inputSnapshot . message && ( ! currentInput || currentInput === inputSnapshot . message )) {
setMessage ( inputSnapshot . message );
2026-07-21 20:52:20 +03:00
writeChatDraft ( chatDraftIdentity , inputSnapshot . message , confirmedMentionsRef . current );
2026-07-17 10:31:56 +03:00
}
2026-02-04 01:14:10 -08:00
const isSoftNetworkError =
normalized . includes ( 'timeout' ) ||
normalized . includes ( 'timed out' ) ||
normalized . includes ( 'may still be processing' ) ||
normalized . includes ( 'being processed' ) ||
normalized . includes ( 'failed to fetch' ) ||
normalized . includes ( 'networkerror' ) ||
normalized . includes ( 'network error' ) ||
normalized . includes ( 'gateway timeout' ) ||
normalized === 'failed to send message' ;
if ( normalized . includes ( 'payload too large' ) || normalized . includes ( '413' ) || normalized . includes ( 'entity too large' )) {
2026-04-26 14:03:39 +03:00
toast . error ( t ( 'chat.chatInput.toast.attachmentsTooLarge' ));
2025-12-29 02:16:42 +02:00
if ( allAttachments . length > 0 ) {
2026-05-08 08:47:46 -04:00
useInputStore . getState (). setAttachedFiles ( allAttachments );
2025-12-07 19:32:53 +02:00
}
2026-02-04 01:14:10 -08:00
return ;
}
if ( isSoftNetworkError ) {
2026-02-11 19:28:22 +02:00
if ( allAttachments . length > 0 ) {
2026-05-08 08:47:46 -04:00
useInputStore . getState (). setAttachedFiles ( allAttachments );
2026-04-26 14:03:39 +03:00
toast . error ( t ( 'chat.chatInput.toast.sendAttachmentsFailed' ));
2026-02-11 19:28:22 +02:00
}
2026-02-04 01:14:10 -08:00
return ;
}
2026-08-07 05:46:21 +08:00
if ( normalized . includes ( 'runtime changed' )) {
if ( allAttachments . length > 0 ) {
useInputStore . getState (). setAttachedFiles ( allAttachments );
}
toast . error ( t ( 'chat.chatInput.toast.messageSendFailed' ));
return ;
}
2026-02-04 01:14:10 -08:00
if ( allAttachments . length > 0 ) {
2026-05-08 08:47:46 -04:00
useInputStore . getState (). setAttachedFiles ( allAttachments );
2026-02-04 01:14:10 -08:00
}
2026-04-26 14:03:39 +03:00
toast . error ( rawMessage || t ( 'chat.chatInput.toast.messageSendFailed' ));
2026-02-04 01:14:10 -08:00
});
2025-12-07 19:32:53 +02:00
2025-12-18 11:20:37 +02:00
if ( ! isMobile ) {
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2025-12-18 11:20:37 +02:00
}
2025-12-07 19:32:53 +02:00
};
2026-03-03 00:20:15 +02:00
// Update ref with latest handleSubmit on every render
2026-02-05 01:59:49 +02:00
handleSubmitRef . current = handleSubmit ;
2026-06-29 09:28:20 +11:00
// Primary action for send/queue button — respects selected follow-up behavior
2026-02-01 22:43:46 +02:00
const handlePrimaryAction = React . useCallback (() => {
2026-05-01 04:26:14 -07:00
const inputSnapshot = getCurrentInputSnapshot ();
2026-06-26 19:29:44 +03:00
const canQueue = inputMode === 'normal' && inputSnapshot . hasContent && currentSessionId && ( sessionPhase !== 'idle' || autoReviewRunning );
2026-06-29 09:28:20 +11:00
if ( followUpBehavior === 'queue' && canQueue ) {
2026-02-01 22:43:46 +02:00
handleQueueMessage ();
2026-06-29 09:28:20 +11:00
} else if ( followUpBehavior === 'steer' && canQueue ) {
void handleSubmitRef . current ({ delivery : 'steer' });
2026-02-01 22:43:46 +02:00
} else {
2026-02-05 01:59:49 +02:00
void handleSubmitRef . current ();
2026-02-01 22:43:46 +02:00
}
2026-06-29 09:28:20 +11:00
}, [ inputMode , getCurrentInputSnapshot , currentSessionId , sessionPhase , autoReviewRunning , followUpBehavior , handleQueueMessage ]);
2025-12-29 02:16:42 +02:00
2026-07-05 02:58:41 +03:00
// Draft welcome presets: submit immediately.
2026-07-29 18:28:19 +03:00
const submitPresetPrompt = React . useCallback (( text : string , type : 'command' | 'skill' ) => {
2026-07-05 02:32:40 +03:00
// 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.
2026-07-27 22:21:38 +03:00
const draft = ( composerRef . current ? . getValue () ?? messageRef . current ). trim ();
2026-07-29 18:28:19 +03:00
// OpenCode recognizes slash commands only when their arguments follow
// the command on the same line. Skills retain the multiline prompt form.
const presetText = draft ? ` ${ text }${ type === 'command' ? ' ' : '\n' }${ draft } ` : text ;
2026-07-12 10:58:58 +03:00
void handleSubmitRef . current ({ presetText });
2026-05-29 02:41:46 +03:00
}, []);
2026-07-04 02:48:07 +03:00
// Dictation: insert the transcript inline; optionally submit immediately.
2026-07-27 22:21:38 +03:00
// getCurrentInputSnapshot reads composerRef.current.getValue() first, so setting
2026-07-04 02:48:07 +03:00
// it synchronously lets handleSubmit pick up the text in the same tick.
const handleDictationInsert = React . useCallback (( text : string ) => {
setMessage (( prev ) => {
2026-07-27 22:21:38 +03:00
// The editor is controlled by this state; getCurrentInputSnapshot
// reads it back, so no imperative write is needed.
return appendInlineText ( prev , text );
2026-07-04 02:48:07 +03:00
});
setTimeout (() => {
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2026-07-04 02:48:07 +03:00
}, 0 );
}, []);
const handleDictationInsertAndSend = React . useCallback (( text : string ) => {
2026-07-05 02:58:41 +03:00
// 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).
2026-07-27 22:21:38 +03:00
const next = appendInlineText ( composerRef . current ? . getValue () ?? messageRef . current , text );
2026-07-05 02:58:41 +03:00
void handleSubmitRef . current ({ presetText : next });
2026-07-04 02:48:07 +03:00
}, []);
2026-05-29 13:31:07 +03:00
// Preset chips rendered outside this component (e.g. under the welcome
// message on narrow surfaces) request a submit via the input store; consume
// it here so it routes through the same command-aware submit path.
React . useEffect (() => {
if ( pendingPresetSubmit == null ) return ;
const text = useInputStore . getState (). consumePendingPresetSubmit ();
2026-07-29 18:28:19 +03:00
if ( text ) submitPresetPrompt ( text . text , text . type );
2026-05-29 13:31:07 +03:00
}, [ pendingPresetSubmit , submitPresetPrompt ]);
2026-07-27 22:21:38 +03:00
const handleKeyDown = ( e : KeyboardEvent ) => {
2026-01-06 21:31:04 +02:00
// Early return during IME composition to prevent interference with autocomplete.
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
2026-01-07 02:48:18 +09:00
if ( isIMECompositionEvent ( e )) return ;
2025-12-07 19:32:53 +02:00
2026-02-18 20:08:42 +02:00
if ( inputMode === 'shell' && e . key === 'Escape' ) {
e . preventDefault ();
setInputMode ( 'normal' );
return ;
2025-12-07 19:32:53 +02:00
}
2026-02-18 20:08:42 +02:00
if ( inputMode === 'shell' && e . key === 'Backspace' && message . length === 0 ) {
e . preventDefault ();
setInputMode ( 'normal' );
return ;
}
2026-07-27 22:21:38 +03:00
if ( openAutocomplete === 'command' && commandRef . current ) {
2025-12-07 19:32:53 +02:00
if ( e . key === 'Enter' || e . key === 'ArrowUp' || e . key === 'ArrowDown' || e . key === 'Escape' || e . key === 'Tab' ) {
e . preventDefault ();
2026-06-12 13:13:36 -04:00
e . stopPropagation ();
2026-02-18 20:08:42 +02:00
commandRef . current . handleKeyDown ( e . key );
2025-12-07 19:32:53 +02:00
return ;
}
}
2026-07-27 22:21:38 +03:00
if ( openAutocomplete === 'skill' && skillRef . current ) {
2026-01-08 19:58:31 +02:00
if ( e . key === 'Enter' || e . key === 'ArrowUp' || e . key === 'ArrowDown' || e . key === 'Escape' || e . key === 'Tab' ) {
e . preventDefault ();
2026-06-12 13:13:36 -04:00
e . stopPropagation ();
2026-01-08 19:58:31 +02:00
skillRef . current . handleKeyDown ( e . key );
return ;
}
}
2026-07-27 22:21:38 +03:00
if ( openAutocomplete === 'snippet' && snippetRef . current ) {
2026-05-21 20:00:35 +03:00
if ( e . key === 'Enter' || e . key === 'ArrowUp' || e . key === 'ArrowDown' || e . key === 'Escape' || e . key === 'Tab' ) {
e . preventDefault ();
2026-06-12 13:13:36 -04:00
e . stopPropagation ();
2026-05-21 20:00:35 +03:00
snippetRef . current . handleKeyDown ( e . key );
return ;
}
}
2026-07-27 22:21:38 +03:00
if ( openAutocomplete === 'mention' && mentionRef . current ) {
2025-12-07 19:32:53 +02:00
if ( e . key === 'Enter' || e . key === 'ArrowUp' || e . key === 'ArrowDown' || e . key === 'Escape' || e . key === 'Tab' ) {
e . preventDefault ();
2026-06-12 13:13:36 -04:00
e . stopPropagation ();
2025-12-07 19:32:53 +02:00
mentionRef . current . handleKeyDown ( e . key );
return ;
}
}
2026-02-23 05:04:25 +07:00
if ( isDesktopExpanded && e . key === 'Escape' ) {
e . preventDefault ();
setExpandedInput ( false );
return ;
}
2026-05-15 00:27:39 +03:00
const cycleAgentBackwardShortcut = cycleAgentShortcut && ! cycleAgentShortcut . includes ( 'shift' )
? normalizeCombo ( `shift+ ${ cycleAgentShortcut } ` )
: '' ;
const cycleAgentDirection = cycleAgentBackwardShortcut && eventMatchesShortcut ( e , cycleAgentBackwardShortcut )
? - 1
: eventMatchesShortcut ( e , cycleAgentShortcut )
? 1
: 0 ;
2026-07-27 22:21:38 +03:00
if ( cycleAgentDirection !== 0 && openAutocomplete === null ) {
2025-12-07 19:32:53 +02:00
e . preventDefault ();
2026-05-15 00:27:39 +03:00
e . stopPropagation ();
handleCycleAgent ( cycleAgentDirection );
2025-12-07 19:32:53 +02:00
return ;
}
2026-02-06 01:14:09 -08:00
// Handle ArrowUp/ArrowDown for message history navigation
2026-06-23 23:21:09 +03:00
// ArrowUp: only when cursor at start (position 0) or input is empty
2026-02-06 01:14:09 -08:00
// ArrowDown: also works when cursor at end (to cycle forward through history)
2026-07-27 22:21:38 +03:00
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 ;
2026-06-23 23:21:09 +03:00
const canNavigateHistoryUp = ! isAnyAutocompleteOpen && ( message . length === 0 || cursorAtStart );
2026-02-06 01:14:09 -08:00
const canNavigateHistoryDown = ! isAnyAutocompleteOpen && ( message . length === 0 || cursorAtEnd );
2026-05-24 17:21:11 +03:00
// Markdown-aware auto-pairing (source mode), normal input only.
if ( inputMode === 'normal' && ! isAnyAutocompleteOpen && ! e . metaKey && ! e . ctrlKey && ! e . altKey ) {
2026-07-27 22:21:38 +03:00
const ta = composerRef . current ;
const selStart = ta ? . getSelection (). start ?? - 1 ;
const selEnd = ta ? . getSelection (). end ?? - 1 ;
2026-05-24 17:21:11 +03:00
if ( ta && selStart >= 0 ) {
const applyEdit = ( next : string , caretStart : number , caretEnd : number ) => {
e . preventDefault ();
setMessage ( next );
2026-07-27 22:21:38 +03:00
composerRef . current ? . setSelection ( caretStart , caretEnd );
2026-05-24 17:21:11 +03:00
updateAutocompleteState ( next , caretEnd );
};
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
const WRAP_PAIRS : Record < string , [ string , string ] > = {
'`' : [ '`' , '`' ], '*' : [ '*' , '*' ], '_' : [ '_' , '_' ], '~' : [ '~' , '~' ],
'(' : [ '(' , ')' ], '[' : [ '[' , ']' ], '{' : [ '{' , '}' ],
'"' : [ '"' , '"' ], "'" : [ "'" , "'" ],
};
if ( selEnd > selStart && WRAP_PAIRS [ e . key ]) {
const [ open , close ] = WRAP_PAIRS [ e . key ];
const selected = message . slice ( selStart , selEnd );
const next = ` ${ message . slice ( 0 , selStart ) }${ open }${ selected }${ close }${ message . slice ( selEnd ) } ` ;
applyEdit ( next , selStart + open . length , selEnd + open . length );
return ;
}
// Typing the third backtick at line start expands into a fenced
// code block with the caret on the empty middle line (Slack-like).
if ( e . key === '`' && selStart === selEnd ) {
const before = message . slice ( 0 , selStart );
if ( /(^|\n)``$/ . test ( before )) {
const after = message . slice ( selEnd );
const next = ` ${ before } \` \ n \ n\`\`\` ${ after } ` ;
const caret = before . length + 2 ; // after the completed ``` and first newline
applyEdit ( next , caret , caret );
return ;
}
}
}
}
2026-07-27 22:21:38 +03:00
if ( e . key === 'ArrowUp' && canNavigateHistoryUp ) {
2026-02-06 01:14:09 -08:00
e . preventDefault ();
2026-07-27 22:21:38 +03:00
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 ));
2026-02-06 01:14:09 -08:00
}
return ;
}
2026-07-27 22:21:38 +03:00
if ( e . key === 'ArrowDown' && canNavigateHistoryDown ) {
2026-02-06 01:14:09 -08:00
e . preventDefault ();
2026-07-27 22:21:38 +03:00
const recalled = messageHistory . newer ();
if ( recalled !== null ) setMessage ( recalled );
2026-02-06 01:14:09 -08:00
return ;
}
2026-06-29 09:28:20 +11:00
// Handle Enter/Ctrl+Enter based on selected follow-up behavior.
2026-03-10 15:35:20 -07:00
if ( e . key === 'Enter' && ! e . shiftKey && ( ! isMobile || e . ctrlKey || e . metaKey )) {
2025-12-07 19:32:53 +02:00
e . preventDefault ();
2026-02-04 01:14:10 -08:00
2025-12-29 02:16:42 +02:00
const isCtrlEnter = e . ctrlKey || e . metaKey ;
2026-02-04 01:14:10 -08:00
2026-06-29 09:28:20 +11:00
// Queueing / steering only works when there's an existing busy
// session (or an active auto-review run).
2026-06-26 19:29:44 +03:00
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && ( sessionPhase !== 'idle' || autoReviewRunning );
2026-02-04 01:14:10 -08:00
2026-06-29 09:28:20 +11:00
if ( followUpBehavior === 'queue' ) {
2025-12-29 02:16:42 +02:00
if ( isCtrlEnter || ! canQueue ) {
handleSubmit ();
} else {
handleQueueMessage ();
}
} else {
2026-06-29 09:28:20 +11:00
// steer: Enter steers into the running turn, Ctrl+Enter sends now.
if ( isCtrlEnter || ! canQueue ) {
2025-12-29 02:16:42 +02:00
handleSubmit ();
2026-06-29 09:28:20 +11:00
} else {
handleSubmit ({ delivery : 'steer' });
2025-12-29 02:16:42 +02:00
}
}
2025-12-07 19:32:53 +02:00
}
};
2026-07-27 22:21:38 +03:00
// 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 ,
2026-02-23 05:04:25 +07:00
message ,
2026-07-27 22:21:38 +03:00
editorRef : composerRef ,
containerRef : dropZoneRef ,
});
2026-02-23 05:04:25 +07:00
2025-12-07 19:32:53 +02:00
const startAbortIndicator = React . useCallback (() => {
if ( abortTimeoutRef . current ) {
clearTimeout ( abortTimeoutRef . current );
abortTimeoutRef . current = null ;
}
setShowAbortStatus ( true );
abortTimeoutRef . current = setTimeout (() => {
setShowAbortStatus ( false );
abortTimeoutRef . current = null ;
}, 1800 );
}, []);
const handleAbort = React . useCallback (() => {
clearAbortPrompt ();
startAbortIndicator ();
2026-03-12 23:45:45 +02:00
void abortCurrentOperation ( currentSessionId || undefined );
}, [ abortCurrentOperation , clearAbortPrompt , currentSessionId , startAbortIndicator ]);
2025-12-07 19:32:53 +02:00
2026-05-15 00:27:39 +03:00
const handleCycleAgent = React . useCallback (( direction : 1 | - 1 = 1 ) => {
const nextAgentName = getCycledPrimaryAgentName ( agents , currentAgentName , direction );
2026-04-27 04:28:51 -06:00
if ( ! nextAgentName ) return ;
2025-12-07 19:32:53 +02:00
2026-04-27 04:28:51 -06:00
setAgent ( nextAgentName );
2025-12-07 19:32:53 +02:00
if ( currentSessionId ) {
2026-04-27 04:28:51 -06:00
saveSessionAgentSelection ( currentSessionId , nextAgentName );
2025-12-07 19:32:53 +02:00
}
2026-04-27 04:28:51 -06:00
}, [ agents , currentAgentName , currentSessionId , setAgent , saveSessionAgentSelection ]);
2025-12-07 19:32:53 +02:00
2026-07-27 22:21:38 +03:00
// 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.
2026-07-07 01:40:00 +03:00
const [ dictationContentHeight , setDictationContentHeight ] = React . useState < number | null >( null );
const handleDictationContentHeightChange = React . useCallback (( height : number | null ) => {
setDictationContentHeight (( prev ) => ( prev === height ? prev : height ));
}, []);
2026-06-23 07:07:09 -04:00
const updateAutocompleteState = React . useCallback ((
value : string ,
cursorPosition : number ,
inputSource : FileMentionAutocompleteInputSource = 'manual' ,
insertedText? : string ,
) => {
2026-07-27 22:21:38 +03:00
const trigger = resolveAutocompleteTrigger ( value , cursorPosition , {
inputMode ,
inputSource ,
insertedText ,
});
setOpenAutocomplete ( trigger ? . kind ?? null );
setAutocompleteQuery ( trigger ? . query ?? '' );
}, [ inputMode ]);
2025-12-07 19:32:53 +02:00
2026-06-23 07:07:09 -04:00
const insertTextAtSelection = React . useCallback ((
text : string ,
inputSource : FileMentionAutocompleteInputSource = 'manual' ,
) => {
2025-12-07 19:32:53 +02:00
if ( ! text ) {
return ;
}
2026-07-27 22:21:38 +03:00
const editor = composerRef . current ;
if ( ! editor ) {
// No mounted editor (collapsed mobile pill): append to the state
// the editor will be seeded from.
2025-12-07 19:32:53 +02:00
const nextValue = message + text ;
setMessage ( nextValue );
2026-06-23 07:07:09 -04:00
updateAutocompleteState ( nextValue , nextValue . length , inputSource , text );
2025-12-07 19:32:53 +02:00
return ;
}
2026-07-27 22:21:38 +03:00
const { start , end } = editor . getSelection ();
2025-12-07 19:32:53 +02:00
const nextValue = ` ${ message . substring ( 0 , start ) }${ text }${ message . substring ( end ) } ` ;
const cursorPosition = start + text . length ;
2026-07-27 22:21:38 +03:00
// 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 );
2026-06-23 07:07:09 -04:00
updateAutocompleteState ( nextValue , cursorPosition , inputSource , text );
2026-07-27 22:21:38 +03:00
}, [ message , updateAutocompleteState ]);
2025-12-07 19:32:53 +02:00
2026-03-23 23:51:55 +02:00
const clearDropTextSuppression = React . useCallback (() => {
suppressNextFileDropTextInsertRef . current = false ;
pendingDroppedAbsolutePathsRef . current = [];
if ( suppressNextFileDropTextInsertTimeoutRef . current ) {
clearTimeout ( suppressNextFileDropTextInsertTimeoutRef . current );
suppressNextFileDropTextInsertTimeoutRef . current = null ;
}
}, []);
const scheduleDropTextSuppressionExpiry = React . useCallback (() => {
if ( suppressNextFileDropTextInsertTimeoutRef . current ) {
clearTimeout ( suppressNextFileDropTextInsertTimeoutRef . current );
}
suppressNextFileDropTextInsertTimeoutRef . current = setTimeout (() => {
clearDropTextSuppression ();
}, 700 );
}, [ clearDropTextSuppression ]);
2026-06-23 07:07:09 -04:00
const clearFileMentionPasteSuppression = React . useCallback (() => {
suppressNextFileMentionPasteRef . current = false ;
if ( suppressNextFileMentionPasteTimeoutRef . current ) {
clearTimeout ( suppressNextFileMentionPasteTimeoutRef . current );
suppressNextFileMentionPasteTimeoutRef . current = null ;
}
}, []);
const markFileMentionPasteSuppression = React . useCallback (() => {
suppressNextFileMentionPasteRef . current = true ;
if ( suppressNextFileMentionPasteTimeoutRef . current ) {
clearTimeout ( suppressNextFileMentionPasteTimeoutRef . current );
}
suppressNextFileMentionPasteTimeoutRef . current = setTimeout (() => {
suppressNextFileMentionPasteRef . current = false ;
suppressNextFileMentionPasteTimeoutRef . current = null ;
}, 700 );
}, []);
2026-07-27 22:21:38 +03:00
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.
2026-03-23 23:51:55 +02:00
if ( isVSCodeRuntime () && suppressNextFileDropTextInsertRef . current ) {
const candidateAbsolutePaths = pendingDroppedAbsolutePathsRef . current ;
2026-07-27 22:21:38 +03:00
if ( candidateAbsolutePaths . some (( path ) => path . length > 0 && value . includes ( path ))) {
2026-03-23 23:51:55 +02:00
clearDropTextSuppression ();
return ;
}
}
2026-07-27 22:21:38 +03:00
const pastedInsertedText = fromPaste ? insertedText : '' ;
2026-06-23 07:07:09 -04:00
const isPasteInput = pastedInsertedText . includes ( '@' ) || suppressNextFileMentionPasteRef . current ;
if ( suppressNextFileMentionPasteRef . current ) {
clearFileMentionPasteSuppression ();
}
2026-07-27 22:21:38 +03:00
const inputSource : FileMentionAutocompleteInputSource = isPasteInput ? 'paste' : 'manual' ;
2026-02-18 20:08:42 +02:00
2026-07-27 22:21:38 +03:00
// A leading `!` switches the composer into shell mode and is consumed.
2026-02-18 20:08:42 +02:00
if ( inputMode === 'normal' && value . startsWith ( '!' )) {
const shellCommand = value . slice ( 1 );
2026-07-27 22:21:38 +03:00
const nextCursor = Math . max ( 0 , selection . start - 1 );
2026-02-18 20:08:42 +02:00
setInputMode ( 'shell' );
setMessage ( shellCommand );
2026-07-27 22:21:38 +03:00
closeAutocomplete ();
requestAnimationFrame (() => composerRef . current ? . setSelection ( nextCursor ));
2026-02-18 20:08:42 +02:00
return ;
}
2025-12-07 19:32:53 +02:00
setMessage ( value );
2026-07-27 22:21:38 +03:00
updateAutocompleteState ( value , selection . start , inputSource , pastedInsertedText );
2025-12-07 19:32:53 +02:00
};
2026-03-23 23:51:55 +02:00
React . useEffect (() => {
return () => {
clearDropTextSuppression ();
2026-06-23 07:07:09 -04:00
clearFileMentionPasteSuppression ();
2026-03-23 23:51:55 +02:00
};
2026-06-23 07:07:09 -04:00
}, [ clearDropTextSuppression , clearFileMentionPasteSuppression ]);
2026-03-23 23:51:55 +02:00
2026-07-27 22:21:38 +03:00
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 () };
2026-05-24 17:21:11 +03:00
// Pasting a URL over a selection wraps it as a markdown link:
// [selected text](pasted url).
if ( inputMode === 'normal' && ( currentSessionId || newSessionDraftOpen )) {
2026-07-27 22:21:38 +03:00
const ta = composerRef . current ;
const selStart = ta ? . getSelection (). start ?? - 1 ;
const selEnd = ta ? . getSelection (). end ?? - 1 ;
2026-05-24 17:21:11 +03:00
if ( ta && selEnd > selStart ) {
const clipboardText = e . clipboardData . getData ( 'text' );
const url = clipboardText . trim ();
const selected = message . slice ( selStart , selEnd );
2026-07-27 22:21:38 +03:00
if ( shouldWrapSelectionAsLink ( url , selected )) {
2026-05-24 17:21:11 +03:00
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 );
2026-07-27 22:21:38 +03:00
composerRef . current ? . setSelection ( caret , caret );
2026-06-23 07:07:09 -04:00
updateAutocompleteState ( next , caret , getFileMentionInputSourceForInsertedText ( url ), url );
2026-05-24 17:21:11 +03:00
return ;
}
}
}
2025-12-07 19:32:53 +02:00
const fileMap = new Map < string , File >();
Array . from ( e . clipboardData . files || []). forEach ( file => {
if ( file . type . startsWith ( 'image/' )) {
fileMap . set ( ` ${ file . name } - ${ file . size } ` , file );
}
});
Array . from ( e . clipboardData . items || []). forEach ( item => {
if ( item . kind === 'file' && item . type . startsWith ( 'image/' )) {
const file = item . getAsFile ();
if ( file ) {
fileMap . set ( ` ${ file . name } - ${ file . size } ` , file );
}
}
});
const imageFiles = Array . from ( fileMap . values ());
2026-06-23 07:07:09 -04:00
const pastedText = e . clipboardData . getData ( 'text' );
2025-12-07 19:32:53 +02:00
if ( imageFiles . length === 0 ) {
2026-06-23 07:07:09 -04:00
if ( pastedText . includes ( '@' )) {
markFileMentionPasteSuppression ();
}
2025-12-07 19:32:53 +02:00
return ;
}
2025-12-21 20:18:51 +02:00
if ( ! currentSessionId && ! newSessionDraftOpen ) {
2026-06-23 07:07:09 -04:00
if ( pastedText . includes ( '@' )) {
markFileMentionPasteSuppression ();
}
2025-12-07 19:32:53 +02:00
return ;
}
e . preventDefault ();
2026-05-24 23:47:22 +03:00
const assignedFilenames = assignImageAttachmentFilenames (
imageFiles ,
[
... attachedFiles . map (( file ) => file . filename ),
... pendingPastedAttachmentFilenamesRef . current ,
],
);
const citationText = buildAttachmentCitationText ( assignedFilenames );
2026-07-27 22:21:38 +03:00
const textarea = composerRef . current ;
const selectionStart = textarea ? . getSelection (). start ?? message . length ;
const selectionEnd = textarea ? . getSelection (). end ?? message . length ;
2026-05-24 23:47:22 +03:00
const insertionText = withInlineInsertionBoundaries (
buildImagePasteInsertion ( pastedText , citationText ),
message . slice ( 0 , selectionStart ),
message . slice ( selectionEnd ),
);
2026-06-23 07:07:09 -04:00
insertTextAtSelection ( insertionText , getFileMentionInputSourceForInsertedText ( insertionText ));
2025-12-07 19:32:53 +02:00
2026-05-24 23:47:22 +03:00
for ( let index = 0 ; index < imageFiles . length ; index += 1 ) {
const filename = assignedFilenames [ index ];
const file = renameFileForAttachmentCitation ( imageFiles [ index ], filename );
pendingPastedAttachmentFilenamesRef . current . add ( filename );
2025-12-07 19:32:53 +02:00
try {
await addAttachedFile ( file );
} catch ( error ) {
console . error ( 'Clipboard image attach failed' , error );
2026-04-26 14:03:39 +03:00
toast . error ( error instanceof Error ? error.message : t ( 'chat.chatInput.toast.clipboardAttachFailed' ));
2026-05-24 23:47:22 +03:00
} finally {
pendingPastedAttachmentFilenamesRef . current . delete ( filename );
2025-12-07 19:32:53 +02:00
}
}
2026-07-27 22:21:38 +03:00
}, [ addAttachedFile , attachedFiles , currentSessionId , inputMode , markFileMentionPasteSuppression , message , newSessionDraftOpen , insertTextAtSelection , setMessage , t , updateAutocompleteState ]);
2025-12-07 19:32:53 +02:00
2026-03-04 01:41:01 +02:00
const handleFileSelect = ( file : { name : string ; path : string ; relativePath? : string }) => {
2025-12-07 19:32:53 +02:00
2026-07-27 22:21:38 +03:00
const cursorPosition = composerRef . current ? . getSelection (). start || 0 ;
2025-12-07 19:32:53 +02:00
const textBeforeCursor = message . substring ( 0 , cursorPosition );
const lastAtSymbol = textBeforeCursor . lastIndexOf ( '@' );
2026-03-04 01:41:01 +02:00
const mentionPath = ( file . relativePath && file . relativePath . trim (). length > 0 )
? file . relativePath . trim ()
2026-07-27 22:21:38 +03:00
: ( toMentionPath ( file . path ) || file . name );
2026-03-04 01:41:01 +02:00
2026-04-22 01:31:14 +08:00
confirmedMentionsRef . current . add ( mentionPath );
2025-12-07 19:32:53 +02:00
if ( lastAtSymbol !== - 1 ) {
const newMessage =
message . substring ( 0 , lastAtSymbol ) +
2026-03-04 01:41:01 +02:00
`@ ${ mentionPath } ` +
2025-12-07 19:32:53 +02:00
message . substring ( cursorPosition );
setMessage ( newMessage );
2026-03-04 01:41:01 +02:00
const nextCursor = lastAtSymbol + mentionPath . length + 2 ;
requestAnimationFrame (() => {
2026-07-27 22:21:38 +03:00
if ( composerRef . current ) {
composerRef . current . setSelection ( nextCursor );
2026-03-04 01:41:01 +02:00
}
updateAutocompleteState ( newMessage , nextCursor );
});
2026-07-27 22:21:38 +03:00
} else if ( composerRef . current ) {
2026-02-04 01:14:10 -08:00
const newMessage =
message . substring ( 0 , cursorPosition ) +
2026-03-04 01:41:01 +02:00
`@ ${ mentionPath } ` +
2026-02-04 01:14:10 -08:00
message . substring ( cursorPosition );
setMessage ( newMessage );
2026-03-04 01:41:01 +02:00
const nextCursor = cursorPosition + mentionPath . length + 2 ;
2026-02-04 01:14:10 -08:00
requestAnimationFrame (() => {
2026-07-27 22:21:38 +03:00
if ( composerRef . current ) {
composerRef . current . setSelection ( nextCursor );
2026-02-04 01:14:10 -08:00
}
updateAutocompleteState ( newMessage , nextCursor );
});
2025-12-07 19:32:53 +02:00
}
2026-07-27 22:21:38 +03:00
closeAutocomplete ();
2025-12-07 19:32:53 +02:00
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2025-12-07 19:32:53 +02:00
};
const handleAgentSelect = ( agentName : string ) => {
2026-07-27 22:21:38 +03:00
const textarea = composerRef . current ;
const cursorPosition = textarea ? . getSelection (). start ?? message . length ;
2025-12-07 19:32:53 +02:00
const textBeforeCursor = message . substring ( 0 , cursorPosition );
2026-02-18 20:08:42 +02:00
const lastAtSymbol = textBeforeCursor . lastIndexOf ( '@' );
2025-12-07 19:32:53 +02:00
2026-02-18 20:08:42 +02:00
if ( lastAtSymbol !== - 1 ) {
2025-12-07 19:32:53 +02:00
const newMessage =
2026-02-18 20:08:42 +02:00
message . substring ( 0 , lastAtSymbol ) +
`@ ${ agentName } ` +
2025-12-07 19:32:53 +02:00
message . substring ( cursorPosition );
setMessage ( newMessage );
2026-02-18 20:08:42 +02:00
const nextCursor = lastAtSymbol + agentName . length + 2 ;
2025-12-07 19:32:53 +02:00
requestAnimationFrame (() => {
2026-07-27 22:21:38 +03:00
if ( composerRef . current ) {
composerRef . current . setSelection ( nextCursor );
2025-12-07 19:32:53 +02:00
}
updateAutocompleteState ( newMessage , nextCursor );
});
2026-07-27 22:21:38 +03:00
} else if ( composerRef . current ) {
2026-02-04 01:14:10 -08:00
const newMessage =
message . substring ( 0 , cursorPosition ) +
2026-02-18 20:08:42 +02:00
`@ ${ agentName } ` +
2026-02-04 01:14:10 -08:00
message . substring ( cursorPosition );
setMessage ( newMessage );
const nextCursor = cursorPosition + agentName . length + 2 ;
requestAnimationFrame (() => {
2026-07-27 22:21:38 +03:00
if ( composerRef . current ) {
composerRef . current . setSelection ( nextCursor );
2026-02-04 01:14:10 -08:00
}
updateAutocompleteState ( newMessage , nextCursor );
});
2025-12-07 19:32:53 +02:00
}
2026-07-27 22:21:38 +03:00
closeAutocomplete ();
2025-12-07 19:32:53 +02:00
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2025-12-07 19:32:53 +02:00
};
2026-01-08 19:58:31 +02:00
const handleSkillSelect = ( skillName : string ) => {
2026-07-27 22:21:38 +03:00
const textarea = composerRef . current ;
const cursorPosition = textarea ? . getSelection (). start ?? message . length ;
2026-01-08 19:58:31 +02:00
const textBeforeCursor = message . substring ( 0 , cursorPosition );
const lastSlashSymbol = textBeforeCursor . lastIndexOf ( '/' );
if ( lastSlashSymbol !== - 1 ) {
const newMessage =
message . substring ( 0 , lastSlashSymbol ) +
2026-02-18 20:08:42 +02:00
`/ ${ skillName } ` +
2026-01-08 19:58:31 +02:00
message . substring ( cursorPosition );
setMessage ( newMessage );
2026-02-18 20:08:42 +02:00
const nextCursor = lastSlashSymbol + skillName . length + 2 ;
2026-01-08 19:58:31 +02:00
requestAnimationFrame (() => {
2026-07-27 22:21:38 +03:00
if ( composerRef . current ) {
composerRef . current . setSelection ( nextCursor );
2026-01-08 19:58:31 +02:00
}
updateAutocompleteState ( newMessage , nextCursor );
});
}
2026-07-27 22:21:38 +03:00
closeAutocomplete ();
2026-01-08 19:58:31 +02:00
2026-07-27 22:21:38 +03:00
composerRef . current ? . focus ();
2026-01-08 19:58:31 +02:00
};
2026-05-21 20:00:35 +03:00
const handleSnippetSelect = ( _snippet : unknown , trigger : string ) => {
2026-07-27 22:21:38 +03:00
const textarea = composerRef . current ;
const cursorPosition = textarea ? . getSelection (). start ?? message . length ;
2026-05-21 20:00:35 +03:00
const textBeforeCursor = message . substring ( 0 , cursorPosition );
const lastHashSymbol = textBeforeCursor . lastIndexOf ( '#' );
const startIndex = lastHashSymbol !== - 1 ? lastHashSymbol : cursorPosition ;
const newMessage = ` ${ message . substring ( 0 , startIndex ) } # ${ trigger } ${ message . substring ( cursorPosition ) } ` ;
setMessage ( newMessage );
const nextCursor = startIndex + trigger . length + 2 ;
requestAnimationFrame (() => {
2026-07-27 22:21:38 +03:00
if ( composerRef . current ) {
composerRef . current . setSelection ( nextCursor );
2026-05-21 20:00:35 +03:00
}
updateAutocompleteState ( newMessage , nextCursor );
});
2026-07-27 22:21:38 +03:00
closeAutocomplete ();
composerRef . current ? . focus ();
2026-05-21 20:00:35 +03:00
};
2026-04-22 22:08:34 +03:00
const handleCommandSelect = ( command : CommandInfo ) => {
2025-12-07 19:32:53 +02:00
setMessage ( `/ ${ command . name } ` );
2026-07-27 22:21:38 +03:00
closeAutocomplete ();
2025-12-07 19:32:53 +02:00
2026-02-04 01:14:10 -08:00
const refocus = () => {
2026-07-27 22:21:38 +03:00
if ( composerRef . current ) {
2026-02-04 01:14:10 -08:00
try {
2026-07-27 22:21:38 +03:00
composerRef . current . focus ({ preventScroll : true });
2026-02-04 01:14:10 -08:00
} catch {
2026-07-27 22:21:38 +03:00
composerRef . current . focus ();
2026-02-04 01:14:10 -08:00
}
2026-07-27 22:21:38 +03:00
composerRef . current . setSelection ( composerRef . current . getValue (). length , composerRef . current . getValue (). length );
2025-12-07 19:32:53 +02:00
}
2026-02-04 01:14:10 -08:00
};
requestAnimationFrame (() => {
refocus ();
requestAnimationFrame ( refocus );
});
setTimeout ( refocus , 60 );
2025-12-07 19:32:53 +02:00
};
React . useEffect (() => {
2026-07-27 22:21:38 +03:00
if ( currentSessionId && composerRef . current && ! isMobile ) {
composerRef . current . focus ();
2025-12-07 19:32:53 +02:00
}
}, [ currentSessionId , isMobile ]);
2026-01-30 06:13:37 -03:00
React . useEffect (() => {
if ( ! isMobile ) {
setMobileControlsPanel ( null );
}
}, [ isMobile ]);
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
if ( abortPromptSessionId && abortPromptSessionId !== currentSessionId ) {
clearAbortPrompt ();
}
}, [ abortPromptSessionId , currentSessionId , clearAbortPrompt ]);
2026-02-11 19:28:22 +02:00
React . useEffect (() => {
canAcceptDropRef . current = Boolean ( currentSessionId || newSessionDraftOpen );
}, [ currentSessionId , newSessionDraftOpen ]);
2026-07-27 22:21:38 +03:00
// Mention paths are shown relative to the project the chat searches.
const toMentionPath = React . useCallback (
( absolutePath : string ) => toProjectRelativeMentionPath ( absolutePath , chatSearchDirectory || "" ),
[ chatSearchDirectory ],
);
2026-03-04 01:41:01 +02:00
2026-03-23 23:51:55 +02:00
const addVSCodeDroppedUrisAsMentions = React . useCallback (( uris : string []) => {
if ( uris . length === 0 ) return ;
2026-04-22 01:31:14 +08:00
const paths = uris
2026-03-23 23:51:55 +02:00
. map (( entry ) => normalizeDroppedPath ( entry ))
2026-07-27 22:21:38 +03:00
. map (( entry ) => toMentionPath ( entry ))
2026-03-23 23:51:55 +02:00
. map (( entry ) => entry . trim (). replace ( /^\.\// , '' ))
2026-04-22 01:31:14 +08:00
. filter (( entry ) => entry . length > 0 );
for ( const p of paths ) {
confirmedMentionsRef . current . add ( p );
}
const mentions = Array . from ( new Set ( paths . map (( entry ) => `@ ${ entry } ` )));
2026-03-23 23:51:55 +02:00
if ( mentions . length === 0 ) {
return ;
}
setPendingInputText ( mentions . join ( ' ' ), 'append-inline' );
2026-04-26 14:03:39 +03:00
toast . success ( t ( 'chat.chatInput.toast.addedFileMentions' , { count : mentions.length }));
2026-07-27 22:21:38 +03:00
}, [ setPendingInputText , t , toMentionPath ]);
2026-03-23 23:51:55 +02:00
2026-02-11 19:28:22 +02:00
const handleDragEnter = ( e : React.DragEvent ) => {
if ( ! hasDraggedFiles ( e . dataTransfer )) {
return ;
}
e . preventDefault ();
e . stopPropagation ();
2026-04-22 01:31:14 +08:00
dragEnterCountRef . current ++ ;
const isInternal = e . dataTransfer . types ? . includes ( 'application/x-openchamber-file-path' ) ?? false ;
if ( isInternal !== isInternalDrag ) {
setIsInternalDrag ( isInternal );
}
2026-02-11 19:28:22 +02:00
if (( currentSessionId || newSessionDraftOpen ) && ! isDragging ) {
setIsDragging ( true );
}
};
2025-12-07 19:32:53 +02:00
const handleDragOver = ( e : React.DragEvent ) => {
2026-02-11 19:28:22 +02:00
if ( ! hasDraggedFiles ( e . dataTransfer )) {
return ;
}
2025-12-07 19:32:53 +02:00
e . preventDefault ();
e . stopPropagation ();
2026-02-11 19:28:22 +02:00
e . dataTransfer . dropEffect = 'copy' ;
2025-12-21 20:18:51 +02:00
if (( currentSessionId || newSessionDraftOpen ) && ! isDragging ) {
2025-12-07 19:32:53 +02:00
setIsDragging ( true );
}
};
const handleDragLeave = ( e : React.DragEvent ) => {
e . preventDefault ();
e . stopPropagation ();
2026-04-22 01:31:14 +08:00
dragEnterCountRef . current -- ;
if ( dragEnterCountRef . current <= 0 ) {
dragEnterCountRef . current = 0 ;
2025-12-07 19:32:53 +02:00
setIsDragging ( false );
2026-04-22 01:31:14 +08:00
setIsInternalDrag ( false );
2026-03-23 23:51:55 +02:00
clearDropTextSuppression ();
2025-12-07 19:32:53 +02:00
}
};
2026-04-22 01:31:14 +08:00
const handleDragEnd = () => {
dragEnterCountRef . current = 0 ;
setIsDragging ( false );
setIsInternalDrag ( false );
clearDropTextSuppression ();
};
2025-12-07 19:32:53 +02:00
const handleDrop = async ( e : React.DragEvent ) => {
2026-04-22 01:31:14 +08:00
dragEnterCountRef . current = 0 ;
2026-03-23 23:51:55 +02:00
const draggedFiles = hasDraggedFiles ( e . dataTransfer );
if ( ! draggedFiles ) {
clearDropTextSuppression ();
2026-02-11 19:28:22 +02:00
return ;
}
2025-12-07 19:32:53 +02:00
e . preventDefault ();
e . stopPropagation ();
setIsDragging ( false );
2025-12-21 20:18:51 +02:00
if ( ! currentSessionId && ! newSessionDraftOpen ) return ;
2025-12-07 19:32:53 +02:00
2026-04-22 01:31:14 +08:00
// Internal drag: file tree → chat input (relative path as @mention)
const internalPath = e . dataTransfer . getData ( 'application/x-openchamber-file-path' );
if ( internalPath && internalPath !== '.' ) {
confirmedMentionsRef . current . add ( internalPath );
const mention = `@ ${ internalPath } ` ;
2026-07-27 22:21:38 +03:00
const textarea = composerRef . current ;
2026-04-22 01:31:14 +08:00
const currentMessage = messageRef . current ;
if ( textarea ) {
2026-07-27 22:21:38 +03:00
const { start : pos , end } = textarea . getSelection ();
2026-04-22 01:31:14 +08:00
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 ? ' ' : '' } ` ;
2026-07-27 22:21:38 +03:00
// 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 ();
2026-04-22 01:31:14 +08:00
} else {
setMessage (( prev ) => appendInlineText ( prev , mention ));
}
clearDropTextSuppression ();
return ;
}
2026-02-11 19:28:22 +02:00
const files = collectDroppedFiles ( e . dataTransfer );
2026-02-28 02:21:12 +08:00
if ( files . length === 0 && isVSCodeRuntime ()) {
const droppedUris = collectDroppedFileUris ( e . dataTransfer );
if ( droppedUris . length > 0 ) {
2026-03-23 23:51:55 +02:00
pendingDroppedAbsolutePathsRef . current = droppedUris
. map (( entry ) => normalizeDroppedPath ( entry ))
. map (( entry ) => entry . trim ())
. filter (( entry ) => entry . length > 0 );
addVSCodeDroppedUrisAsMentions ( droppedUris );
} else {
clearDropTextSuppression ();
2026-02-28 02:21:12 +08:00
}
return ;
}
2026-02-11 19:28:22 +02:00
if ( files . length > 0 ) {
2026-07-22 11:33:22 +03:00
let attached = false ;
2026-02-11 19:28:22 +02:00
for ( const file of files ) {
try {
2026-07-22 11:33:22 +03:00
attached = ( await addAttachedFile ( file )) || attached ;
2026-02-11 19:28:22 +02:00
} catch ( error ) {
console . error ( 'File attach failed' , error );
2025-12-07 19:32:53 +02:00
}
}
2026-07-22 11:33:22 +03:00
if ( ! attached ) toast . error ( t ( 'chat.chatInput.toast.attachFileFailed' ));
2025-12-07 19:32:53 +02:00
}
2026-03-23 23:51:55 +02:00
clearDropTextSuppression ();
};
2025-12-07 19:32:53 +02:00
2026-03-23 23:51:55 +02:00
const handleDropCapture = ( e : React.DragEvent ) => {
if ( ! hasDraggedFiles ( e . dataTransfer )) {
return ;
}
2026-04-22 01:31:14 +08:00
// Prevent native textarea drop text insertion for all runtimes
2026-03-23 23:51:55 +02:00
e . preventDefault ();
2026-04-22 01:31:14 +08:00
if ( isVSCodeRuntime ()) {
suppressNextFileDropTextInsertRef . current = true ;
scheduleDropTextSuppressionExpiry ();
}
2025-12-07 19:32:53 +02:00
};
2025-12-15 13:15:45 +02:00
const fileInputRef = React . useRef < HTMLInputElement >( null );
const attachFiles = React . useCallback ( async ( files : FileList | File []) => {
const list = Array . isArray ( files ) ? files : Array.from ( files );
2026-07-22 11:33:22 +03:00
let attached = false ;
2025-12-15 13:15:45 +02:00
for ( const file of list ) {
try {
2026-07-22 11:33:22 +03:00
attached = ( await addAttachedFile ( file )) || attached ;
2025-12-15 13:15:45 +02:00
} catch ( error ) {
console . error ( 'File attach failed' , error );
}
}
2026-07-22 11:33:22 +03:00
if ( list . length > 0 && ! attached ) {
toast . error ( t ( 'chat.chatInput.toast.attachFileFailed' ));
}
2026-04-26 16:58:07 +03:00
}, [ addAttachedFile , t ]);
2025-12-15 13:15:45 +02:00
const handleVSCodePickFiles = React . useCallback ( async () => {
try {
2026-07-22 11:33:22 +03:00
const data = ( await vscodeApi ? . pickFiles ? .({ extensions : ACCEPTED_ATTACHMENT_EXTENSIONS })) as {
2026-06-02 00:43:05 +03:00
files? : Array < { name : string ; mimeType? : string ; dataUrl? : string } > ;
skipped? : Array < { name? : string ; reason? : string } > ;
} | undefined ;
2025-12-15 13:15:45 +02:00
const picked = Array . isArray ( data ? . files ) ? data . files : [];
const skipped = Array . isArray ( data ? . skipped ) ? data . skipped : [];
if ( skipped . length > 0 ) {
const summary = skipped
. map (( s : { name? : string ; reason? : string }) => ` ${ s ? . name || 'file' } : ${ s ? . reason || 'skipped' } ` )
. join ( '\n' );
2026-04-26 14:03:39 +03:00
toast . error ( t ( 'chat.chatInput.toast.someFilesSkipped' , { summary }));
2025-12-15 13:15:45 +02:00
}
const asFiles = picked
. map (( file : { name : string ; mimeType? : string ; dataUrl? : string }) => {
if ( ! file ? . dataUrl ) return null ;
try {
const [ meta , base64 ] = file . dataUrl . split ( ',' );
const mime = file . mimeType || ( meta ? . match ( /data:(.*);base64/ ) ? .[ 1 ] || 'application/octet-stream' );
if ( ! base64 ) return null ;
const binary = atob ( base64 );
const bytes = new Uint8Array ( binary . length );
for ( let i = 0 ; i < binary . length ; i ++ ) {
bytes [ i ] = binary . charCodeAt ( i );
}
const blob = new Blob ([ bytes ], { type : mime });
return new File ([ blob ], file . name || 'file' , { type : mime });
} catch ( err ) {
console . error ( 'Failed to decode VS Code picked file' , err );
return null ;
}
})
. filter ( Boolean ) as File [];
if ( asFiles . length > 0 ) {
await attachFiles ( asFiles );
}
} catch ( error ) {
console . error ( 'VS Code file pick failed' , error );
2026-04-26 14:03:39 +03:00
toast . error ( error instanceof Error ? error.message : t ( 'chat.chatInput.toast.vscodePickFailed' ));
2025-12-15 13:15:45 +02:00
}
2026-06-02 00:43:05 +03:00
}, [ attachFiles , t , vscodeApi ]);
2025-12-15 13:15:45 +02:00
const handlePickLocalFiles = React . useCallback (() => {
if ( isVSCodeRuntime ()) {
void handleVSCodePickFiles ();
return ;
}
fileInputRef . current ? . click ();
}, [ handleVSCodePickFiles ]);
const handleLocalFileSelect = React . useCallback ( async ( event : React.ChangeEvent < HTMLInputElement >) => {
const files = event . target . files ;
if ( ! files ) return ;
await attachFiles ( files );
event . target . value = '' ;
}, [ attachFiles ]);
2026-01-29 22:27:26 +02:00
const footerGapClass = 'gap-x-1.5 gap-y-0' ;
2025-12-15 13:15:45 +02:00
const isVSCode = isVSCodeRuntime ();
2026-03-20 01:01:03 +02:00
const showDraftTargetSelectors = newSessionDraftOpen && ! isVSCode ;
2026-07-27 22:21:38 +03:00
// 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 );
2026-03-20 01:01:03 +02:00
2026-05-08 12:22:59 +03:00
const chatSurfaceMode = useChatSurfaceMode ();
const isMiniChatSurface = chatSurfaceMode === 'mini-chat' ;
2026-04-23 10:55:27 +03:00
const hasPendingChanges = React . useMemo (() => {
2026-05-08 12:22:59 +03:00
if ( isMiniChatSurface ) {
return false ;
}
2026-04-23 10:55:27 +03:00
if ( isGitRepo !== true || ! currentGitStatus || currentGitStatus . isClean ) {
return false ;
}
return extractGitChangedFiles ( currentGitStatus . files , currentGitStatus . diffStats , currentDirectory ). length > 0 ;
2026-05-08 12:22:59 +03:00
}, [ currentDirectory , currentGitStatus , isGitRepo , isMiniChatSurface ]);
2026-04-23 10:55:27 +03:00
2026-03-20 01:01:03 +02:00
React . useEffect (() => {
if ( ! showDraftTargetSelectors || ! selectedDraftProject || ! selectedDraftDirectory ) {
return ;
}
2026-03-22 22:31:29 +02:00
if ( newSessionDraft ? . pendingWorktreeRequestId || newSessionDraft ? . bootstrapPendingDirectory || newSessionDraft ? . preserveDirectoryOverride ) {
return ;
}
2026-03-20 01:01:03 +02:00
const valid = draftBranchItems . some (( option ) => option . value === selectedDraftDirectory );
if ( valid ) {
return ;
}
setNewSessionDraftTarget ({
projectId : selectedDraftProject.id ,
directoryOverride : selectedDraftProject.path ,
});
2026-03-22 22:31:29 +02:00
}, [ draftBranchItems , newSessionDraft ? . bootstrapPendingDirectory , newSessionDraft ? . pendingWorktreeRequestId , newSessionDraft ? . preserveDirectoryOverride , selectedDraftDirectory , selectedDraftProject , setNewSessionDraftTarget , showDraftTargetSelectors ]);
2026-03-20 01:01:03 +02:00
2026-07-27 22:21:38 +03:00
// 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 ,
2026-08-02 16:25:16 +03:00
// The pill exists to buy screen back from the soft keyboard. A tablet
// has the room regardless, and with a hardware keyboard there is no
// soft keyboard to buy it back from — keep the real composer up.
alwaysExpanded : hasHardwareKeyboard || isTabletLayout ,
2026-07-27 22:21:38 +03:00
holders : {
controlsPanelOpen : Boolean ( mobileControlsPanel ),
attachMenuOpen : mobileAttachMenuOpen ,
draftPickerOpen : mobileDraftPicker !== null ,
issuePickerOpen ,
prPickerOpen ,
isDragging ,
},
});
const mobileComposerExpanded = mobileShell . expanded ;
const mobileTextareaFocused = mobileShell . focused ;
2026-07-04 16:41:17 +03:00
2026-07-05 23:19:10 +03:00
const applyAssistSuggestion = React . useCallback (( text : string ) => {
setMessage ( text );
if ( isMobile && ! mobileComposerExpanded ) {
2026-07-27 22:21:38 +03:00
mobileShell . expand ();
2026-07-05 23:19:10 +03:00
} else {
2026-07-27 22:21:38 +03:00
requestAnimationFrame (() => composerRef . current ? . focus ());
2026-07-05 23:19:10 +03:00
}
2026-07-27 22:21:38 +03:00
}, [ isMobile , mobileComposerExpanded , mobileShell ]);
2026-07-05 23:19:10 +03:00
2026-07-04 16:41:17 +03:00
const handleMobileNewSession = React . useCallback (() => {
if ( newSessionDraftOpen ) return ;
openNewSessionDraft ( currentDirectory ? { directoryOverride : currentDirectory } : undefined );
}, [ newSessionDraftOpen , openNewSessionDraft , currentDirectory ]);
2026-07-27 22:21:38 +03:00
/** The dictation engine listens for this globally; the composer only asks. */
const toggleDictation = React . useCallback (() => {
window . dispatchEvent ( new CustomEvent ( 'openchamber:dictation-toggle' ));
}, []);
2026-07-04 16:41:17 +03:00
const openMobileAttachSheet = React . useCallback (() => {
2026-07-08 21:52:18 +03:00
// 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.
2026-07-04 16:41:17 +03:00
setMobileAttachMenuOpen ( true );
2026-07-27 22:21:38 +03:00
composerRef . current ? . blur ();
2026-07-04 16:41:17 +03:00
}, []);
2026-07-05 00:05:38 +03:00
2026-07-04 16:41:17 +03:00
// Reset the picker search whenever a draft picker sheet opens/closes.
React . useEffect (() => {
setMobileDraftPickerQuery ( '' );
}, [ mobileDraftPicker ]);
2026-07-27 22:21:38 +03:00
// Mobile browsers pan the visual viewport instead of resizing the layout,
// so the composer form is pinned to it explicitly.
useMobileViewportPin ({
2026-07-04 16:41:17 +03:00
isMobile ,
2026-07-27 22:21:38 +03:00
isFullscreen : isMobileExpanded ,
isDraftScreen : newSessionDraftOpen ,
isFocused : mobileTextareaFocused ,
formRef : composerFormRef ,
editorRef : composerRef ,
});
2026-07-04 16:41:17 +03:00
2025-12-15 13:15:45 +02:00
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : ( isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5' );
2026-02-01 22:43:46 +02:00
const buttonSizeClass = isMobile ? 'h-8 w-8' : ( isVSCode ? 'h-5 w-5' : 'h-6 w-6' );
const sendIconSizeClass = isMobile ? 'h-4 w-4' : ( isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4' );
const stopIconSizeClass = isMobile ? 'h-6 w-6' : ( isVSCode ? 'h-4 w-4' : 'h-5 w-5' );
2026-02-04 01:14:10 -08:00
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : ( isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]' );
2025-12-07 19:32:53 +02:00
2026-03-03 22:23:21 +00:00
const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed' ;
2026-02-09 13:55:10 -08:00
const footerIconButtonClass = cn ( iconButtonBaseClass , buttonSizeClass );
2026-03-20 01:01:03 +02:00
const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId ;
const permissionAutoAcceptEnabled = usePermissionStore (( state ) => {
if ( ! permissionScopeSessionId ) {
2026-07-11 23:15:02 +11:00
return draftPermissionAutoAcceptEnabled ;
2026-03-20 01:01:03 +02:00
}
return state . isSessionAutoAccepting ( permissionScopeSessionId );
});
2026-07-11 23:15:02 +11:00
const isPermissionAutoAcceptInteractive = Boolean ( permissionScopeSessionId || newSessionDraftOpen );
2026-03-20 01:01:03 +02:00
const handlePermissionAutoAcceptToggle = React . useCallback (() => {
2026-07-11 23:15:02 +11:00
togglePermissionAutoAccept ({
permissionScopeSessionId ,
newSessionDraftOpen ,
draftPermissionAutoAcceptEnabled ,
permissionAutoAcceptEnabled ,
setDraftPermissionAutoAcceptEnabled ,
setSessionAutoAccept ,
onOpenSessionFirst : () => toast . error ( t ( 'chat.chatInput.toast.openSessionFirst' )),
onToggleFailed : () => toast . error ( t ( 'chat.chatInput.toast.togglePermissionAutoAcceptFailed' )),
2026-03-20 01:01:03 +02:00
});
2026-07-11 23:15:02 +11:00
}, [
draftPermissionAutoAcceptEnabled ,
newSessionDraftOpen ,
permissionAutoAcceptEnabled ,
permissionScopeSessionId ,
setDraftPermissionAutoAcceptEnabled ,
setSessionAutoAccept ,
t ,
]);
2026-03-20 01:01:03 +02:00
2025-12-07 19:32:53 +02:00
React . useEffect (() => {
2026-04-05 15:36:11 +03:00
const pendingAbortBanner = Boolean ( abortPromptSessionId ) && abortPromptSessionId === currentSessionId ;
2025-12-07 19:32:53 +02:00
if ( ! prevWasAbortedRef . current && pendingAbortBanner && ! showAbortStatus ) {
startAbortIndicator ();
if ( currentSessionId ) {
acknowledgeSessionAbort ( currentSessionId );
}
}
prevWasAbortedRef . current = pendingAbortBanner ;
}, [
2026-04-05 15:36:11 +03:00
abortPromptSessionId ,
2025-12-07 19:32:53 +02:00
acknowledgeSessionAbort ,
currentSessionId ,
showAbortStatus ,
startAbortIndicator ,
]);
React . useEffect (() => {
return () => {
if ( abortTimeoutRef . current ) {
clearTimeout ( abortTimeoutRef . current );
abortTimeoutRef . current = null ;
}
};
}, []);
return (
2026-02-23 05:04:25 +07:00
<>
2026-01-01 16:02:41 +02:00
< form
2026-07-05 09:14:55 +03:00
ref = { composerFormRef }
2026-02-01 22:43:46 +02:00
onSubmit = {( e ) => { e . preventDefault (); handlePrimaryAction (); }}
2026-01-14 19:25:18 +08:00
className = { cn (
2026-05-27 17:35:58 +03:00
"relative w-full pt-0 pb-4" ,
2026-02-23 05:04:25 +07:00
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4' ,
2026-07-04 16:41:17 +03:00
isMobileExpanded && 'flex h-full min-h-0 flex-col pt-2' ,
2026-07-01 09:55:41 +03:00
isMobile && 'bottom-safe-area oc-mobile-composer'
2026-01-14 19:25:18 +08:00
)}
2026-04-26 17:44:33 +03:00
style = { isMobile && inputBarOffset > 0 ? { marginBottom : ` ${ inputBarOffset } px` } : undefined }
2026-01-01 16:02:41 +02:00
>
2026-05-28 01:20:21 +03:00
{ newSessionDraftOpen && ! isDesktopExpanded && ! isMobile && ! isVSCode && ! isMiniChatSurface ? (
2026-05-27 17:35:58 +03:00
< div className = "chat-input-column mb-7 text-center" >
2026-05-28 01:25:55 +03:00
< h1 className = "text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl" >
{ renderDraftTitle (
draftProjectLabel
? t ( 'chat.emptyState.draftTitleWithProject' , { project : draftProjectLabel })
: t ( 'chat.emptyState.draftTitle' ),
draftProjectLabel ,
)}
2026-05-27 17:35:58 +03:00
</ h1 >
</ div >
) : null }
2026-07-04 16:41:17 +03:00
< div className = { cn ( 'chat-input-column relative overflow-visible' , isComposerExpanded && 'flex flex-1 min-h-0 flex-col' )}>
2026-05-24 23:18:32 +03:00
< AttachedFilesList onShowPopup = { handleShowAttachmentPreview } />
2026-02-03 15:05:01 -03:00
< QueuedMessageChips
2026-04-04 02:19:55 +03:00
onEditMessage = { handleQueuedMessageEdit }
2026-05-27 17:13:45 +03:00
onSendMessage = { handleQueuedMessageSend }
2025-12-29 02:16:42 +02:00
/>
2026-06-26 19:29:44 +03:00
< AutoReviewBanner />
2026-07-27 22:21:38 +03:00
{ hasDrafts ? (
< ComposerContextChips
terminalDrafts = { terminalContextDrafts }
reviewCount = { reviewCount }
2026-07-27 23:07:42 +03:00
prCommentCount = { prCommentCount }
prCheckCount = { prCheckCount }
2026-07-27 22:21:38 +03:00
previewConsoleCount = { previewConsoleCount }
previewAnnotationCount = { previewAnnotationCount }
draftTarget = { inlineDraftTarget }
onRemoveDraft = { removeInlineCommentDraft }
onRemoveReviewDrafts = { removeReviewDrafts }
onRemovePreviewDrafts = { removePreviewDrafts }
colors = { currentTheme . colors }
/>
) : null }
2026-03-03 00:20:15 +02:00
2026-07-27 22:21:38 +03:00
{ linkedIssue && ! isVSCode ? (
< LinkedReferenceRow
numberLabel = { `# ${ linkedIssue . number } ` }
title = { linkedIssue . title }
url = { linkedIssue . url }
author = { linkedIssue . author }
openInBrowserLabel = { t ( 'chat.chatInput.linked.issue.openInBrowserAria' )}
removeLabel = { t ( 'chat.chatInput.linked.issue.removeAria' )}
onReopenPicker = {() => setIssuePickerOpen ( true )}
onRemove = {() => setLinkedIssue ( null )}
/>
) : null }
{ linkedPr && ! isVSCode ? (
< LinkedReferenceRow
numberLabel = { t ( 'chat.chatInput.linked.pr.number' , { number : linkedPr . number })}
title = { linkedPr . title }
url = { linkedPr . url }
author = { linkedPr . author }
branches = {{ head : linkedPr.head , base : linkedPr.base }}
openInBrowserLabel = { t ( 'chat.chatInput.linked.pr.openInBrowserAria' )}
removeLabel = { t ( 'chat.chatInput.linked.pr.removeAria' )}
onReopenPicker = {() => setPrPickerOpen ( true )}
onRemove = {() => setLinkedPr ( null )}
/>
) : null }
2026-05-16 21:44:37 +08:00
< RevertedMessageDock
sessionId = { currentSessionId }
directory = { currentSessionDirectoryForSync ?? currentDirectory }
/>
2026-04-04 02:19:55 +03:00
< MemoStatusRow
2026-03-12 23:45:45 +02:00
showAbortStatus = { showAbortStatus }
showAssistantStatus = { false }
showTodos
2026-04-23 10:55:27 +03:00
leftAccessory = { newSessionDraftOpen || ! hasPendingChanges ? null : < PendingChangesBar />}
2026-03-12 23:45:45 +02:00
/>
2026-07-04 16:41:17 +03:00
{ ! isMobile && showDraftTargetSelectors && selectedDraftProject ? (
2026-07-27 22:21:38 +03:00
< DraftTargetSelectors
projects = { draftProjects }
selectedProject = { selectedDraftProject }
selectedDirectory = { selectedDraftDirectory }
selectedBranchLabel = { selectedDraftBranchLabel }
selectedBranchIsKnown = { selectedDraftBranchIsKnown }
projectRootBranchOption = { projectRootBranchOption }
worktreeBranchOptions = { worktreeBranchOptions }
branchItems = { draftBranchItems }
showBranchSelector = { shouldShowDraftBranchSelector }
onProjectChange = { handleDraftProjectChange }
onDirectoryChange = { handleDraftDirectoryChange }
theme = { currentTheme }
/>
2026-03-20 01:01:03 +02:00
) : null }
2026-07-04 16:41:17 +03:00
{ isMobile && showDraftTargetSelectors && selectedDraftProject ? (
2026-07-27 22:21:38 +03:00
< MobileDraftTargetTriggers
selectedProject = { selectedDraftProject }
selectedBranchLabel = { selectedDraftBranchLabel }
showBranchSelector = { shouldShowDraftBranchSelector }
theme = { currentTheme }
onOpenPicker = { setMobileDraftPicker }
/>
2026-07-04 16:41:17 +03:00
) : null }
< div
// Desktop: layout-transparent. Mobile: positioning host for
// the wrapper-level dictation overlay across pill/full states.
className = { cn (
! isMobile && 'contents' ,
isMobile && 'relative' ,
isMobileExpanded && 'flex min-h-0 flex-1 flex-col' ,
)}
>
{ isMobile && ! mobileComposerExpanded ? (
2026-07-27 22:21:38 +03:00
< MobilePillComposer
message = { message }
2026-07-05 23:19:10 +03:00
sessionId = { currentSessionId }
2026-07-06 20:00:53 +03:00
directory = { currentSessionDirectoryForSync ?? currentDirectory }
2026-07-27 22:21:38 +03:00
newSessionDraftOpen = { newSessionDraftOpen }
hasContent = { Boolean ( hasContent )}
isVSCode = { isVSCode }
2026-08-01 21:16:36 +03:00
canAbort = { canAbort }
2026-07-27 22:21:38 +03:00
footerIconButtonClass = { footerIconButtonClass }
iconSizeClass = { iconSizeClass }
2026-08-01 21:16:36 +03:00
stopIconSizeClass = { stopIconSizeClass }
2026-07-27 22:21:38 +03:00
theme = { currentTheme }
onExpand = { mobileShell . expand }
onApplySuggestion = { applyAssistSuggestion }
onNewSession = { handleMobileNewSession }
onPickLocalFiles = { handlePickLocalFiles }
onOpenIssuePicker = { openIssuePicker }
onOpenPrPicker = { openPrPicker }
onOpenAttachSheet = { openMobileAttachSheet }
onStartDictation = { toggleDictation }
2026-08-01 21:16:36 +03:00
onAbort = { handleAbort }
2026-07-05 23:19:10 +03:00
/>
2026-07-04 16:41:17 +03:00
) : (
2026-07-05 23:19:10 +03:00
<>
2026-07-12 01:23:22 +03:00
< SessionGoalRow
sessionId = { currentSessionId }
directory = { currentSessionDirectoryForSync ?? currentDirectory }
className = "mb-1.5"
/>
2026-07-05 23:19:10 +03:00
< SessionSuggestionChip
sessionId = { currentSessionId }
2026-07-06 20:00:53 +03:00
directory = { currentSessionDirectoryForSync ?? currentDirectory }
2026-07-05 23:19:10 +03:00
hidden = { hasContent || newSessionDraftOpen }
onApply = { applyAssistSuggestion }
className = "mb-1.5"
/>
2025-12-07 19:32:53 +02:00
< div
className = { cn (
2026-02-09 03:13:34 +02:00
"flex flex-col relative overflow-visible" ,
2026-07-04 16:41:17 +03:00
isComposerExpanded && 'flex-1 min-h-0' ,
2026-02-01 18:29:34 +02:00
"border border-border/80" ,
2026-07-18 00:56:32 +03:00
"shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]" ,
2026-02-18 20:08:42 +02:00
"focus-within:ring-1" ,
inputMode === 'shell'
? 'focus-within:ring-[var(--status-info)]'
: 'focus-within:ring-primary/50' ,
2026-02-11 19:28:22 +02:00
isDragging && "ring-2 ring-primary ring-offset-2"
2025-12-07 19:32:53 +02:00
)}
2026-02-01 18:29:34 +02:00
style = {{
2026-03-20 01:01:03 +02:00
borderRadius : chatInputRadius ,
2026-02-01 18:29:34 +02:00
backgroundColor : currentTheme?.colors?.surface?.subtle ,
}}
2026-02-11 19:28:22 +02:00
ref = { dropZoneRef }
2026-03-23 23:51:55 +02:00
onDropCapture = { handleDropCapture }
2026-02-11 19:28:22 +02:00
onDragEnter = { handleDragEnter }
onDragOver = { handleDragOver }
onDragLeave = { handleDragLeave }
onDrop = { handleDrop }
2026-04-22 01:31:14 +08:00
onDragEnd = { handleDragEnd }
2025-12-07 19:32:53 +02:00
>
2026-02-11 19:28:22 +02:00
{ isDragging && (
2026-04-07 21:46:38 +03:00
< div className = "absolute inset-0 z-50 flex items-center justify-center bg-background/90 rounded-xl" >
2026-02-11 19:28:22 +02:00
< div className = "text-center" >
< div className = "inline-flex justify-center" >
< button
type = "button"
className = { iconButtonBaseClass }
onClick = {() => handlePickLocalFiles ()}
2026-04-26 14:03:39 +03:00
title = { t ( 'chat.chatInput.actions.attachFiles' )}
aria-label = { t ( 'chat.chatInput.actions.attachFiles' )}
2026-02-11 19:28:22 +02:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "attachment-2" className = { cn ( iconSizeClass , 'text-current' )} />
2026-02-11 19:28:22 +02:00
</ button >
</ div >
2026-04-26 14:03:39 +03:00
< p className = "mt-2 typography-ui-label text-muted-foreground" >
{ isInternalDrag ? t ( 'chat.chatInput.drop.insertMention' ) : t ( 'chat.chatInput.drop.attachFiles' )}
</ p >
2026-02-11 19:28:22 +02:00
</ div >
</ div >
)}
2026-02-04 01:14:10 -08:00
2026-07-27 22:21:38 +03:00
< ComposerAutocompletePopups
open = { openAutocomplete }
query = { autocompleteQuery }
overlayPosition = { isDesktopExpanded ? autocompleteOverlayPosition : null }
commandRef = { commandRef }
skillRef = { skillRef }
snippetRef = { snippetRef }
mentionRef = { mentionRef }
onCommandSelect = { handleCommandSelect }
onSkillSelect = { handleSkillSelect }
onSnippetSelect = { handleSnippetSelect }
onFileSelect = { handleFileSelect }
onAgentSelect = { handleAgentSelect }
onClose = { closeAutocomplete }
/>
2026-07-04 02:48:07 +03:00
{ /* Positioning context for the dictation overlay: covers the
2026-08-01 21:16:36 +03:00
text area + footer exactly. */ }
2026-07-04 16:41:17 +03:00
< div className = { cn ( 'relative flex flex-col' , isComposerExpanded && 'flex-1 min-h-0' )}>
< div className = { cn ( "overflow-hidden" , isComposerExpanded && 'flex flex-1 min-h-0 flex-col' )}>
{ isMobile ? (
< div className = "scrollbar-none relative z-10 flex items-center gap-x-2 overflow-x-auto px-3 pb-0.5 pt-1.5" >
< MemoMobileModelButton onOpenModel = {() => handleOpenMobilePanel ( 'model' )} className = "flex-shrink-0" />
< MemoMobileAgentButton
onOpenAgentPanel = { handleOpenAgentPanel }
onCycleAgent = { handleCycleAgent }
className = "flex-shrink-0"
/>
</ div >
) : null }
2026-05-05 00:24:03 +02:00
< div className = "flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10" >
2026-05-24 23:18:32 +03:00
< AttachedVSCodeFileChips onShowPopup = { handleShowAttachmentPreview } />
2026-05-05 00:24:03 +02:00
< ActiveEditorFileSuggestion />
</ div >
2026-07-27 22:21:38 +03:00
< div
className = { cn ( "relative overflow-hidden" , isComposerExpanded && 'flex flex-1 min-h-0 flex-col' )}
onDragEnter = { handleDragEnter }
onDragOver = { handleDragOver }
onDropCapture = { handleDropCapture }
onDrop = { handleDrop }
onDragEnd = { handleDragEnd }
style = { dictationContentHeight !== null
? { minHeight : ` ${ dictationContentHeight } px` }
: undefined }
>
< ComposerEditor
ref = { composerRef }
viewStore = { composerViewStore }
data-testid = "chat-input"
2026-05-05 00:24:03 +02:00
value = { message }
2026-07-27 22:21:38 +03:00
languageContext = { languageContext }
onChange = { handleComposerChange }
onKeyDown = {( event ) => {
// Every interception branch calls
// preventDefault, so the event itself
// reports whether the composer consumed it.
handleKeyDown ( event );
return event . defaultPrevented ;
2026-05-05 00:24:03 +02:00
}}
2026-07-27 22:21:38 +03:00
onPaste = { handlePaste }
onSelectionChange = {( selection ) => {
cursorPosRef . current = selection . start ;
2026-05-05 00:24:03 +02:00
updateAutocompleteOverlayPosition ();
}}
2026-07-27 22:21:38 +03:00
onFocus = { mobileShell . onEditorFocus }
onBlur = { mobileShell . onEditorBlur }
2026-05-05 00:24:03 +02:00
placeholder = { currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t ( 'chat.chatInput.placeholder.shell' )
2026-05-21 20:00:35 +03:00
: t ( useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat' )
2026-05-05 00:24:03 +02:00
: t ( 'chat.chatInput.placeholder.selectSession' )}
2026-07-27 22:21:38 +03:00
editable = { Boolean ( currentSessionId || newSessionDraftOpen )}
autoCorrect = { isMobile }
autoCapitalize = { isMobile ? 'sentences' : 'none' }
2026-05-05 00:24:03 +02:00
spellCheck = { isMobile || inputSpellcheckEnabled }
2026-07-04 16:41:17 +03:00
fillContainer = { isComposerExpanded }
2026-07-27 22:21:38 +03:00
maxLines = { isMobile ? MAX_MOBILE_COMPOSER_LINES : MAX_VISIBLE_COMPOSER_LINES }
boundSelector = { isMobile ? '[data-composer-bound]' : undefined }
boundGapPx = { MOBILE_COMPOSER_BOUND_GAP_PX }
2026-03-04 01:41:01 +02:00
className = { cn (
2026-07-27 22:21:38 +03:00
'min-h-[52px] px-3 relative z-10' ,
2026-07-04 16:41:17 +03:00
isComposerExpanded
? cn ( 'h-full min-h-0' , isMobile ? 'py-2.5' : 'py-4' )
2026-03-04 01:41:01 +02:00
: isMobile
? 'py-2.5'
: 'pt-4 pb-2' ,
2026-07-27 22:21:38 +03:00
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label' ,
2026-03-04 01:41:01 +02:00
)}
2026-05-05 00:24:03 +02:00
/>
</ div >
2026-03-04 01:41:01 +02:00
</ div >
2026-07-27 22:21:38 +03:00
< ComposerFooter
isMobile = { isMobile }
isVSCode = { isVSCode }
sessionId = { currentSessionId }
directory = { currentSessionDirectoryForSync ?? currentDirectory }
newSessionDraftOpen = { newSessionDraftOpen }
messageLength = { message . length }
radius = { chatInputRadius }
footerPaddingClass = { footerPaddingClass }
footerGapClass = { footerGapClass }
footerIconButtonClass = { footerIconButtonClass }
iconSizeClass = { iconSizeClass }
sendIconSizeClass = { sendIconSizeClass }
stopIconSizeClass = { stopIconSizeClass }
canSend = { canSend }
canAbort = { canAbort }
hasContent = { Boolean ( hasContent )}
isExpandedInput = { isExpandedInput }
permissionAutoAcceptEnabled = { permissionAutoAcceptEnabled }
isPermissionAutoAcceptInteractive = { isPermissionAutoAcceptInteractive }
dictationActive = { mobileShell . dictationActive }
onOpenSettings = { onOpenSettings }
onPickLocalFiles = { handlePickLocalFiles }
onOpenIssuePicker = { openIssuePicker }
onOpenPrPicker = { openPrPicker }
onOpenAttachSheet = { openMobileAttachSheet }
onToggleExpandedInput = { handleToggleExpandedInput }
onTogglePermissionAutoAccept = { handlePermissionAutoAcceptToggle }
onPrimaryAction = { handlePrimaryAction }
onQueueMessage = { handleQueueMessage }
onAbort = { handleAbort }
onStartDictation = { toggleDictation }
onDictationInsert = { handleDictationInsert }
onDictationInsertAndSend = { handleDictationInsertAndSend }
onDictationContentHeightChange = { handleDictationContentHeightChange }
/>
2026-07-04 02:48:07 +03:00
</ div >
2025-12-07 19:32:53 +02:00
2026-02-07 09:11:55 +08:00
</ div >
2026-07-05 23:19:10 +03:00
</>
2026-07-04 16:41:17 +03:00
)}
{ /* Wrapper-level dictation engine + overlay: stays mounted across
the pill ↔ composer swap so a recording started from the pill
survives the morph. Its absolute overlay covers whichever
shape the wrapper currently has. */ }
{ isMobile ? (
< MemoComposerDictation
radius = { chatInputRadius }
isMobile = { isMobile }
footerIconButtonClass = { footerIconButtonClass }
footerPaddingClass = { footerPaddingClass }
iconSizeClass = { iconSizeClass }
sendIconSizeClass = { sendIconSizeClass }
onInsert = { handleDictationInsert }
onInsertAndSend = { handleDictationInsertAndSend }
2026-07-27 22:21:38 +03:00
onActiveChange = { mobileShell . onDictationActiveChange }
2026-07-07 01:40:00 +03:00
onContentHeightChange = { handleDictationContentHeightChange }
2026-07-04 16:41:17 +03:00
renderTrigger = { false }
/>
) : null }
</ div >
{ /* Hidden host for the model/agent/variant bottom sheets. Kept
outside the pill conditional so an open panel survives (and
stays visible over) the collapsed composer. */ }
{ isMobile ? (
< MemoModelControls
className = "hidden"
mobilePanel = { mobileControlsPanel }
onMobilePanelChange = { setMobileControlsPanel }
/>
) : null }
2025-12-07 19:32:53 +02:00
</ div >
2026-05-29 02:41:46 +03:00
{ newSessionDraftOpen && ! isDesktopExpanded && ! isMobile && ! isVSCode && ! isMiniChatSurface ? (
2026-07-29 18:28:19 +03:00
< DraftPresetChips
onSubmit = {( starter ) => submitPresetPrompt ( starter . submitText , starter . ref . type )}
className = "chat-input-column mt-4"
/>
2026-05-29 02:41:46 +03:00
) : null }
2025-12-07 19:32:53 +02:00
</ form >
2026-03-03 00:20:15 +02:00
{ /* Issue Picker Dialog */ }
< GitHubIssuePickerDialog
open = { issuePickerOpen }
onOpenChange = { setIssuePickerOpen }
mode = "select"
2026-03-04 01:41:01 +02:00
onSelect = {( issue ) => {
setLinkedIssue ( issue );
setLinkedPr ( null );
}}
/>
< GitHubPrPickerDialog
open = { prPickerOpen }
onOpenChange = { setPrPickerOpen }
onSelect = {( pr ) => {
setLinkedPr ( pr );
setLinkedIssue ( null );
}}
2026-03-03 00:20:15 +02:00
/>
2026-06-14 16:55:58 +03:00
< ReviewFlowDialog
open = { reviewDialogOpen }
onOpenChange = { setReviewDialogOpen }
projectDirectory = { currentSessionDirectoryForSync ?? currentDirectory ?? null }
submitting = { reviewFlowSubmitting }
onConfirm = { handleStartReviewFlow }
/>
2026-05-24 23:18:32 +03:00
< ToolOutputDialog
popup = { attachmentPreview }
onOpenChange = { handleAttachmentPreviewOpenChange }
isMobile = { isMobile }
/>
2026-07-04 16:41:17 +03:00
2026-07-07 10:32:13 +03:00
{ /* Single always-mounted picker input. It must NOT live inside
ComposerAttachmentControls: that component mounts once per composer
variant (pill / expanded footer), so a shared ref got nulled when a
variant unmounted, and a variant swap while the OS file picker was
open detached the clicked input — its change event was silently
lost and the picked files never attached. */ }
< input
ref = { fileInputRef }
type = "file"
multiple
className = "hidden"
onChange = { handleLocalFileSelect }
2026-07-22 11:33:22 +03:00
accept = { ATTACHMENT_ACCEPT }
2026-07-07 10:32:13 +03:00
/>
2026-07-04 16:41:17 +03:00
{ /* Mobile attachment sheet: replaces the dropdown (which stole focus and
dismissed the keyboard) and leaves room for more actions later. */ }
{ isMobile ? (
< MobileOverlayPanel
open = { mobileAttachMenuOpen }
title = { t ( 'chat.chatInput.actions.addAttachment' )}
onClose = {() => setMobileAttachMenuOpen ( false )}
>
< div className = "flex flex-col px-3 pb-4 pt-1" >
< button
type = "button"
className = "flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
onClick = {() => {
// The native file/photo picker takes over next — restoring
// the keyboard in between would flash it open and shut.
2026-07-27 22:21:38 +03:00
mobileShell . cancelOverlayCloseRestore ();
2026-07-04 16:41:17 +03:00
setMobileAttachMenuOpen ( false );
requestAnimationFrame ( handlePickLocalFiles );
}}
>
< Icon name = "attachment-2" className = "h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{ t ( 'chat.chatInput.actions.attachFiles' )}
</ button >
< button
type = "button"
className = "flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
onClick = {() => {
2026-07-07 01:25:28 +03:00
// Hand-off to the picker: don't sync-restore the
// keyboard under the overlay that opens next frame.
2026-07-27 22:21:38 +03:00
mobileShell . skipNextOverlayCloseRestore ();
2026-07-04 16:41:17 +03:00
setMobileAttachMenuOpen ( false );
requestAnimationFrame ( openIssuePicker );
}}
>
< Icon name = "github" className = "h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{ t ( 'chat.chatInput.actions.linkGithubIssue' )}
</ button >
< button
type = "button"
className = "flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
onClick = {() => {
2026-07-27 22:21:38 +03:00
mobileShell . skipNextOverlayCloseRestore ();
2026-07-04 16:41:17 +03:00
setMobileAttachMenuOpen ( false );
requestAnimationFrame ( openPrPicker );
}}
>
< Icon name = "git-pull-request" className = "h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
{ t ( 'chat.chatInput.actions.linkGithubPr' )}
</ button >
</ div >
</ MobileOverlayPanel >
) : null }
{ /* Mobile draft target pickers: bottom sheets replacing the inline
project/branch Selects (which desktop keeps). */ }
{ isMobile && showDraftTargetSelectors && selectedDraftProject ? (
2026-07-27 22:21:38 +03:00
< MobileDraftTargetSheets
projects = { draftProjects }
selectedProject = { selectedDraftProject }
selectedDirectory = { selectedDraftDirectory }
selectedBranchLabel = { selectedDraftBranchLabel }
selectedBranchIsKnown = { selectedDraftBranchIsKnown }
projectRootBranchOption = { projectRootBranchOption }
worktreeBranchOptions = { worktreeBranchOptions }
branchItems = { draftBranchItems }
showBranchSelector = { shouldShowDraftBranchSelector }
onProjectChange = { handleDraftProjectChange }
onDirectoryChange = { handleDraftDirectoryChange }
theme = { currentTheme }
openPicker = { mobileDraftPicker }
onOpenPickerChange = { setMobileDraftPicker }
query = { mobileDraftPickerQuery }
onQueryChange = { setMobileDraftPickerQuery }
/>
2026-07-04 16:41:17 +03:00
) : null }
2026-02-23 05:04:25 +07:00
</>
2025-12-07 19:32:53 +02:00
);
};
2026-04-05 15:36:11 +03:00
ChatInputComponent . displayName = 'ChatInput' ;
export const ChatInput = React . memo ( ChatInputComponent );