2025-12-07 19:32:53 +02:00
import React from 'react' ;
import { Textarea } from '@/components/ui/textarea' ;
2026-02-09 13:55:10 -08:00
import { BrowserVoiceButton } from '@/components/voice' ;
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' ;
2025-12-29 02:16:42 +02:00
import { useMessageQueueStore , type QueuedMessage } from '@/stores/messageQueueStore' ;
2026-03-31 18:47:00 +03:00
import { useSessionUIStore } from '@/sync/session-ui-store' ;
import { useSelectionStore } from '@/sync/selection-store' ;
import { useInputStore } from '@/sync/input-store' ;
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-05-16 21:44:37 +08:00
import { useDirectorySync , useUserMessageHistory } from '@/sync/sync-context' ;
2026-02-05 03:14:26 +02:00
import { useInlineCommentDraftStore , type InlineCommentDraft } 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-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' ;
2025-12-07 19:32:53 +02:00
import { FileMentionAutocomplete , type FileMentionHandle } from './FileMentionAutocomplete' ;
2026-04-22 22:08:34 +03:00
import { CommandAutocomplete , type CommandAutocompleteHandle , type CommandInfo } from './CommandAutocomplete' ;
2026-01-08 19:58:31 +02:00
import { SkillAutocomplete , type SkillAutocompleteHandle } from './SkillAutocomplete' ;
2026-05-21 20:00:35 +03:00
import { SnippetAutocomplete , type SnippetAutocompleteHandle } from './SnippetAutocomplete' ;
2026-03-20 01:01:03 +02:00
import { cn , formatDirectoryName , isMacOS } 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' ;
2026-02-07 09:11:55 +08:00
import { MobileSessionStatusBar } from './MobileSessionStatusBar' ;
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-05-16 21:44:37 +08:00
import { Button } from '@/components/ui/button' ;
2026-03-31 18:47:00 +03:00
// useMessageStore removed — messages now come from sync system
2026-03-04 01:41:01 +02:00
import { isTauriShell , isVSCodeRuntime } from '@/lib/desktop' ;
2026-01-06 21:31:04 +02:00
import { isIMECompositionEvent } from '@/lib/ime' ;
2026-01-29 18:23:43 +02:00
import { StopIcon } from '@/components/icons/StopIcon' ;
2026-02-23 05:04:25 +07:00
import { Tooltip , TooltipContent , TooltipTrigger } from '@/components/ui/tooltip' ;
2026-04-27 04:28:51 -06:00
import { getCycledPrimaryAgentName , type MobileControlsPanel } from './mobileControlsUtils' ;
2025-12-15 13:15:45 +02:00
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
} from '@/components/ui/dropdown-menu' ;
2026-03-20 01:01:03 +02:00
import { Select , SelectContent , SelectGroup , SelectItem , SelectLabel , SelectSeparator , SelectTrigger , SelectValue } from '@/components/ui/select' ;
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-03-04 01:41:01 +02:00
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory' ;
import { opencodeClient } from '@/lib/opencode/client' ;
2026-03-20 01:01:03 +02:00
import { useProjectsStore } from '@/stores/useProjectsStore' ;
import { PROJECT_COLOR_MAP , PROJECT_ICON_MAP , getProjectIconImageUrl } from '@/lib/projectMeta' ;
2026-04-23 10:55:27 +03:00
import { useGitBranches , useGitStore , useIsGitRepo } from '@/stores/useGitStore' ;
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-03-22 22:31:29 +02:00
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator' ;
2026-04-17 01:13:59 +08:00
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract' ;
2026-03-20 01:01:03 +02:00
import { usePermissionStore } from '@/stores/permissionStore' ;
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-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-16 21:44:37 +08:00
import { isSyntheticPart } from '@/lib/messages/synthetic' ;
2026-05-24 17:21:11 +03:00
import {
buildHighlightParts ,
mentionRangesToHighlightRanges ,
tokenizeMarkdown ,
type HighlightRange ,
type MentionRange ,
} from './composerHighlight' ;
import { highlightFencedCode } from './composerCodeHighlight' ;
2026-05-24 23:47:22 +03:00
import {
assignImageAttachmentFilenames ,
buildAttachmentCitationText ,
findAttachmentCitationRanges ,
} from './attachmentCitations' ;
2026-05-16 21:44:37 +08:00
import type { Message , Part } from '@opencode-ai/sdk/v2/client' ;
2025-12-07 19:32:53 +02:00
const MAX_VISIBLE_TEXTAREA_LINES = 8 ;
2025-12-29 02:16:42 +02:00
const EMPTY_QUEUE : QueuedMessage [] = [];
2026-05-16 21:44:37 +08:00
const EMPTY_MESSAGES : Message [] = [];
2026-03-04 01:41:01 +02:00
const FILE_MENTION_TOKEN = /^@[^\s]+$/ ;
2026-05-24 17:21:11 +03:00
// Single-line URL pasted over a selection becomes a markdown link.
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i ;
2026-05-17 00:47:51 +03:00
const INLINE_SKILL_TOKEN_PATTERN = /(^|\s)\/([a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?)/g ;
2026-03-20 01:01:03 +02:00
const CHAT_DRAFT_PERSIST_DEBOUNCE_MS = 500 ;
2026-05-21 20:00:35 +03:00
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560 ;
2026-03-23 23:51:55 +02:00
const VS_CODE_DROP_DATA_TYPES = [
'CodeFiles' ,
'codefiles' ,
'application/vnd.code.tree' ,
'application/vnd.code.tree.explorer' ,
'text/uri-list' ,
'text/plain' ,
];
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 ,
});
};
const buildImagePasteInsertion = ( pastedText : string , citationText : string ) : string => {
const text = pastedText ;
if ( ! text ) {
return citationText ;
}
return ` ${ text }${ / \ s$ / . test ( text ) ? '' : ' ' }${ citationText } ` ;
};
const withInlineInsertionBoundaries = ( content : string , before : string , after : string ) : string => {
if ( ! content ) {
return content ;
}
const needsLeadingSpace = before . length > 0
&& ! /\s$/ . test ( before )
&& ! /^\s/ . test ( content )
&& ! /[([{]$/ . test ( before );
const needsTrailingSpace = after . length > 0
&& ! /\s$/ . test ( content )
&& ! /^\s/ . test ( after )
&& ! /^[\])}.,;:!?]/ . test ( after );
return ` ${ needsLeadingSpace ? ' ' : '' }${ content }${ needsTrailingSpace ? ' ' : '' } ` ;
};
2026-05-17 00:47:51 +03:00
const collectInlineSkillMentions = ( text : string , skillNames : Set < string >) : string [] => {
const mentions : string [] = [];
INLINE_SKILL_TOKEN_PATTERN . lastIndex = 0 ;
let match : RegExpExecArray | null ;
while (( match = INLINE_SKILL_TOKEN_PATTERN . exec ( text )) !== null ) {
const name = match [ 2 ] || '' ;
2026-05-18 19:11:29 +03:00
if ( ! skillNames . has ( name ) || mentions . includes ( name )) {
2026-05-17 00:47:51 +03:00
continue ;
}
mentions . push ( name );
}
return mentions ;
};
const buildSkillMentionInstruction = ( skillNames : string []) : string | null => {
if ( skillNames . length === 0 ) return null ;
const formatted = skillNames . map (( name ) => `/ ${ name } ` ). join ( ', ' );
return `The user explicitly mentioned these skills in their message: ${ formatted } . Use the corresponding skill tool when it is relevant to accomplishing the user's request.` ;
};
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-16 21:44:37 +08:00
const getRevertedPreview = ( parts : Part [], fallback : string ) : string => {
const text = parts
. filter (( part ) => part . type === 'text' && ! isSyntheticPart ( part ))
. map (( part ) => {
const record = part as Record < string , unknown >;
return typeof record . text === 'string'
? record.text
: typeof record . content === 'string'
? record . content
: '' ;
})
. join ( '\n' )
. replace ( /\s+/g , ' ' )
. trim ();
if ( text ) return text ;
const filePart = parts . find (( part ) => part . type === 'file' ) as ( Part & { filename? : string }) | undefined ;
return filePart ? . filename ? `[ ${ filePart . filename } ]` : fallback ;
};
2026-03-23 23:51:55 +02:00
const FILE_URI_PREFIX = 'file://' ;
2026-03-31 18:47:00 +03:00
const encodeFilePath = ( filepath : string ) : string => {
let normalized = filepath . replace ( /\\/g , '/' );
if ( /^[A-Za-z]:/ . test ( normalized )) {
normalized = `/ ${ normalized } ` ;
}
return normalized
. split ( '/' )
. map (( segment , index ) => {
if ( index === 1 && /^[A-Za-z]:$/ . test ( segment )) return segment ;
return encodeURIComponent ( segment );
})
. join ( '/' );
};
const toServerFileUrl = ( filepath : string ) : string => {
const normalized = filepath . replace ( /\\/g , '/' ). trim ();
if ( normalized . toLowerCase (). startsWith ( FILE_URI_PREFIX )) {
return normalized ;
}
return `file:// ${ encodeFilePath ( normalized ) } ` ;
};
2026-03-23 23:51:55 +02:00
const isLikelyAbsolutePath = ( value : string ) : boolean => (
value . startsWith ( '/' )
|| value . startsWith ( '\\\\' )
|| /^[A-Za-z]:[\\/]/ . test ( value )
);
const toLikelyFileDropReference = ( value : string ) : string | null => {
const trimmed = value . trim (). replace ( /^['"]+|['"]+$/g , '' );
if ( ! trimmed ) {
return null ;
}
if ( /[\r\n]/ . test ( trimmed )) {
return null ;
}
if ( trimmed . toLowerCase (). startsWith ( FILE_URI_PREFIX )) {
return trimmed ;
}
if ( isLikelyAbsolutePath ( trimmed )) {
return trimmed ;
}
return null ;
};
const collectStringLeaves = ( input : unknown , output : Set < string >, depth = 0 ) : void => {
if ( depth > 6 || input == null ) {
return ;
}
if ( typeof input === 'string' ) {
output . add ( input );
return ;
}
if ( Array . isArray ( input )) {
for ( const item of input ) {
collectStringLeaves ( item , output , depth + 1 );
}
return ;
}
if ( typeof input !== 'object' ) {
return ;
}
for ( const value of Object . values ( input )) {
collectStringLeaves ( value , output , depth + 1 );
}
};
const parseDroppedFileReferences = ( rawPayload : string ) : string [] => {
const extracted = new Set < string >();
const addCandidatesFromText = ( value : string ) : void => {
const direct = toLikelyFileDropReference ( value );
if ( direct ) {
extracted . add ( direct );
return ;
}
for ( const line of value . split ( /\r?\n/ )) {
const candidate = toLikelyFileDropReference ( line );
if ( candidate ) {
extracted . add ( candidate );
}
}
};
addCandidatesFromText ( rawPayload );
try {
const parsed = JSON . parse ( rawPayload ) as unknown ;
const leaves = new Set < string >();
collectStringLeaves ( parsed , leaves );
for ( const leaf of leaves ) {
addCandidatesFromText ( leaf );
}
} catch {
// Ignore non-JSON payloads.
}
return Array . from ( extracted );
};
2026-03-20 01:01:03 +02:00
const normalizePath = ( value? : string | null ) : string | null => {
if ( typeof value !== 'string' ) {
return null ;
}
const trimmed = value . trim ();
if ( ! trimmed ) {
return null ;
}
const normalized = trimmed . replace ( /\\/g , '/' );
if ( normalized === '/' ) {
return '/' ;
}
return normalized . length > 1 ? normalized . replace ( /\/+$/ , '' ) : normalized ;
};
const getProjectDisplayLabel = ( project : { label? : string ; path : string }) : string => {
const label = project . label ? . trim ();
if ( label ) {
return label ;
}
return formatDirectoryName ( project . path );
};
const getProjectIconColor = ( projectColor? : string | null ) : string | undefined => {
if ( ! projectColor ) {
return undefined ;
}
return PROJECT_COLOR_MAP [ projectColor ] ?? undefined ;
};
2025-12-07 19:32:53 +02:00
2026-04-04 02:19:55 +03:00
const MemoModelControls = React . memo ( ModelControls );
const MemoBrowserVoiceButton = React . memo ( BrowserVoiceButton );
const MemoMobileAgentButton = React . memo ( MobileAgentButton );
const MemoMobileModelButton = React . memo ( MobileModelButton );
const MemoStatusRow = React . memo ( StatusRow );
2026-05-16 21:44:37 +08:00
type RevertedMessageDockProps = {
sessionId : string | null ;
directory? : string ;
};
const RevertedMessageDock : React.FC < RevertedMessageDockProps > = React . memo (({ sessionId , directory }) => {
const { t } = useI18n ();
const revertToMessage = useSessionUIStore (( s ) => s . revertToMessage );
const forkFromMessage = useSessionUIStore (( s ) => s . forkFromMessage );
const handleSlashRedo = useSessionUIStore (( s ) => s . handleSlashRedo );
const [ restoringId , setRestoringId ] = React . useState < string | null >( null );
const [ forkingId , setForkingId ] = React . useState < string | null >( null );
const [ collapsed , setCollapsed ] = React . useState ( true );
const revertMessageID = useDirectorySync (
React . useCallback (( state ) => {
if ( ! sessionId ) return undefined ;
const session = state . session . find (( item ) => item . id === sessionId );
return ( session as { revert ?: { messageID? : string } } | undefined ) ? . revert ? . messageID ;
}, [ sessionId ]),
directory ,
);
const sessionMessages = useDirectorySync (
React . useCallback (( state ) => ( sessionId ? state . message [ sessionId ] ?? EMPTY_MESSAGES : EMPTY_MESSAGES ), [ sessionId ]),
directory ,
);
const partsByMessage = useDirectorySync ( React . useCallback (( state ) => state . part , []), directory );
const userMessages = React . useMemo (
() => sessionMessages . filter (( message ) : message is Message & { role : 'user' } => message . role === 'user' ),
[ sessionMessages ],
);
const noTextContent = t ( 'chat.revertPopover.noTextContent' );
const items = React . useMemo (() => {
if ( ! revertMessageID ) return [];
return userMessages
. filter (( message ) => message . id >= revertMessageID )
. map (( message ) => ({
id : message.id ,
text : getRevertedPreview ( partsByMessage [ message . id ] ?? [], noTextContent ),
}));
}, [ noTextContent , partsByMessage , revertMessageID , userMessages ]);
const firstRevertedMessageId = items [ 0 ] ? . id ;
React . useEffect (() => {
setCollapsed ( true );
}, [ revertMessageID , firstRevertedMessageId ]);
const handleRestore = React . useCallback ( async ( messageId : string ) => {
if ( ! sessionId || restoringId ) return ;
setRestoringId ( messageId );
try {
const nextMessage = userMessages . find (( message ) => message . id > messageId );
if ( nextMessage ) {
await revertToMessage ( sessionId , nextMessage . id , { skipRedoPush : true });
} else {
await handleSlashRedo ( sessionId , { fullUnrevert : true });
}
} finally {
setRestoringId ( null );
}
}, [ handleSlashRedo , revertToMessage , restoringId , sessionId , userMessages ]);
const handleFork = React . useCallback ( async ( messageId : string ) => {
if ( ! sessionId || forkingId ) return ;
setForkingId ( messageId );
try {
await forkFromMessage ( sessionId , messageId );
} finally {
setForkingId ( null );
}
}, [ forkFromMessage , forkingId , sessionId ]);
if ( ! sessionId || items . length === 0 ) return null ;
return (
< div className = "pb-2 w-full px-1" >
< div className = "rounded-xl border border-border/60 bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] shadow-sm overflow-hidden" >
< button
type = "button"
className = "flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[var(--interactive-hover)] transition-colors"
onClick = {() => setCollapsed (( value ) => ! value )}
aria-expanded = { ! collapsed }
>
< span className = "typography-ui-label font-medium text-foreground flex-shrink-0" >
{ t ( 'chat.revertPopover.title' )} messages { items . length }
</ span >
< Icon
name = "arrow-down-s"
className = { cn ( "ml-auto h-4 w-4 text-muted-foreground transition-transform" , ! collapsed && "rotate-180" )}
aria-hidden = "true"
/>
</ button >
{ ! collapsed && (
< div className = "px-3 pb-3 flex flex-col gap-1.5 max-h-[10.5rem] overflow-y-auto" >
{ items . map (( item ) => (
< div key = { item . id } className = "flex min-w-0 items-center gap-2 py-1" >
< span className = "min-w-0 flex-1 truncate typography-ui-label text-foreground" >
{ item . text }
</ span >
< Button
type = "button"
variant = "secondary"
size = "xs"
disabled = { Boolean ( restoringId || forkingId )}
onClick = {() => { void handleFork ( item . id ); }}
>
{ forkingId === item . id ? (
< Icon name = "loader-4" className = "h-3 w-3 animate-spin" aria-hidden = "true" />
) : (
< Icon name = "git-branch" className = "h-3 w-3" aria-hidden = "true" />
)}
{ t ( 'chat.revertPopover.fork' )}
</ Button >
< Button
type = "button"
variant = "secondary"
size = "xs"
disabled = { Boolean ( restoringId || forkingId )}
onClick = {() => { void handleRestore ( item . id ); }}
>
{ restoringId === item . id ? (
< Icon name = "loader-4" className = "h-3 w-3 animate-spin" aria-hidden = "true" />
) : (
< Icon name = "arrow-go-forward" className = "h-3 w-3" aria-hidden = "true" />
)}
{ t ( 'chat.revertPopover.restore' )}
</ Button >
</ div >
))}
</ div >
)}
</ div >
</ div >
);
});
RevertedMessageDock . displayName = 'RevertedMessageDock' ;
2026-04-04 02:19:55 +03:00
type ComposerAttachmentControlsProps = {
isVSCode : boolean ;
footerIconButtonClass : string ;
iconSizeClass : string ;
fileInputRef : React.RefObject < HTMLInputElement | null >;
handleLocalFileSelect : ( event : React.ChangeEvent < HTMLInputElement >) => void | Promise < void >;
handlePickLocalFiles : () => void ;
openIssuePicker : () => void ;
openPrPicker : () => void ;
onOpenSettings ?: () => void ;
};
const ComposerAttachmentControls = React . memo ( function ComposerAttachmentControls ( props : ComposerAttachmentControlsProps ) {
2026-04-26 14:03:39 +03:00
const { t } = useI18n ();
2026-04-04 02:19:55 +03:00
const {
isVSCode ,
footerIconButtonClass ,
iconSizeClass ,
fileInputRef ,
handleLocalFileSelect ,
handlePickLocalFiles ,
openIssuePicker ,
openPrPicker ,
onOpenSettings ,
} = props ;
return (
< div className = "flex items-center gap-x-1.5" >
< input
ref = { fileInputRef }
type = "file"
multiple
className = "hidden"
onChange = { handleLocalFileSelect }
accept = "*/*"
/>
< div className = "relative inline-flex" >
{ isVSCode ? (
< button
type = "button"
className = { footerIconButtonClass }
onClick = { handlePickLocalFiles }
2026-04-26 14:03:39 +03:00
title = { t ( 'chat.chatInput.actions.attachFiles' )}
aria-label = { t ( 'chat.chatInput.actions.attachFiles' )}
2026-04-04 02:19:55 +03:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "attachment-2" className = { cn ( iconSizeClass , 'text-current' )} />
2026-04-04 02:19:55 +03:00
</ button >
) : (
< DropdownMenu >
< DropdownMenuTrigger asChild >
< button
type = "button"
className = { footerIconButtonClass }
2026-04-26 14:03:39 +03:00
title = { t ( 'chat.chatInput.actions.addAttachment' )}
aria-label = { t ( 'chat.chatInput.actions.addAttachment' )}
2026-04-04 02:19:55 +03:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "add-circle" className = { cn ( iconSizeClass , 'text-current' )} />
2026-04-04 02:19:55 +03:00
</ button >
</ DropdownMenuTrigger >
< DropdownMenuContent align = "start" >
< DropdownMenuItem
onSelect = {() => {
requestAnimationFrame ( handlePickLocalFiles );
}}
>
2026-05-13 13:26:15 +03:00
< Icon name = "attachment-2" />
2026-04-26 14:03:39 +03:00
{ t ( 'chat.chatInput.actions.attachFiles' )}
2026-04-04 02:19:55 +03:00
</ DropdownMenuItem >
< DropdownMenuItem
onSelect = {() => {
requestAnimationFrame ( openIssuePicker );
}}
>
2026-05-13 13:26:15 +03:00
< Icon name = "github" />
2026-04-26 14:03:39 +03:00
{ t ( 'chat.chatInput.actions.linkGithubIssue' )}
2026-04-04 02:19:55 +03:00
</ DropdownMenuItem >
< DropdownMenuItem
onSelect = {() => {
requestAnimationFrame ( openPrPicker );
}}
>
2026-05-13 13:26:15 +03:00
< Icon name = "git-pull-request" />
2026-04-26 14:03:39 +03:00
{ t ( 'chat.chatInput.actions.linkGithubPr' )}
2026-04-04 02:19:55 +03:00
</ DropdownMenuItem >
</ DropdownMenuContent >
</ DropdownMenu >
)}
</ div >
{ onOpenSettings ? (
< button
type = "button"
onClick = { onOpenSettings }
className = { footerIconButtonClass }
2026-04-26 14:03:39 +03:00
title = { t ( 'chat.chatInput.actions.modelAgentSettings' )}
aria-label = { t ( 'chat.chatInput.actions.modelAgentSettings' )}
2026-04-04 02:19:55 +03:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "ai-agent" className = { cn ( iconSizeClass , 'text-current' )} />
2026-04-04 02:19:55 +03:00
</ button >
) : null }
</ div >
);
}, ( prev , next ) => (
2026-05-25 21:27:10 +03:00
prev . isVSCode === next . isVSCode
2026-04-04 02:19:55 +03:00
&& prev . footerIconButtonClass === next . footerIconButtonClass
&& prev . iconSizeClass === next . iconSizeClass
&& prev . onOpenSettings === next . onOpenSettings
));
type PermissionAutoAcceptButtonProps = {
footerIconButtonClass : string ;
iconSizeClass : string ;
permissionScopeSessionId : string | null ;
permissionAutoAcceptEnabled : boolean ;
handlePermissionAutoAcceptToggle : () => void ;
withTooltip? : boolean ;
};
const PermissionAutoAcceptButton = React . memo ( function PermissionAutoAcceptButton ( props : PermissionAutoAcceptButtonProps ) {
2026-04-26 14:03:39 +03:00
const { t } = useI18n ();
2026-04-04 02:19:55 +03:00
const {
footerIconButtonClass ,
iconSizeClass ,
permissionScopeSessionId ,
permissionAutoAcceptEnabled ,
handlePermissionAutoAcceptToggle ,
withTooltip = false ,
} = props ;
const ariaLabel = permissionAutoAcceptEnabled
2026-04-26 14:03:39 +03:00
? t ( 'chat.chatInput.permissionAutoAccept.disable' )
: t ( 'chat.chatInput.permissionAutoAccept.enable' );
2026-04-04 02:19:55 +03:00
const tooltipLabel = permissionAutoAcceptEnabled
2026-04-26 14:03:39 +03:00
? t ( 'chat.chatInput.permissionAutoAccept.on' )
: t ( 'chat.chatInput.permissionAutoAccept.off' );
2026-04-04 02:19:55 +03:00
const button = (
< button
type = "button"
onClick = { handlePermissionAutoAcceptToggle }
className = { cn (
footerIconButtonClass ,
'rounded-md hover:bg-transparent' ,
! permissionScopeSessionId && 'opacity-30' ,
)}
onMouseDown = {( event ) => {
event . preventDefault ();
}}
onPointerDownCapture = {( event ) => {
if ( event . pointerType === 'touch' ) {
event . preventDefault ();
event . stopPropagation ();
}
}}
aria-pressed = { permissionAutoAcceptEnabled }
aria-label = { ariaLabel }
title = { ariaLabel }
>
{ permissionAutoAcceptEnabled ? (
2026-05-13 13:26:15 +03:00
< Icon name = "shield-check" className = { cn ( iconSizeClass )} style = {{ color : 'var(--status-info)' }} />
2026-04-04 02:19:55 +03:00
) : (
2026-05-13 13:26:15 +03:00
< Icon name = "shield-user" className = { cn ( iconSizeClass )} />
2026-04-04 02:19:55 +03:00
)}
</ button >
);
if ( ! withTooltip ) {
return button ;
}
return (
2026-04-30 22:41:33 +03:00
< Tooltip >
2026-04-04 02:19:55 +03:00
< TooltipTrigger asChild >
{ button }
</ TooltipTrigger >
< TooltipContent side = "top" sideOffset = { 8 }>
{ tooltipLabel }
</ TooltipContent >
</ Tooltip >
);
});
type FocusModeButtonProps = {
footerIconButtonClass : string ;
iconSizeClass : string ;
isExpandedInput : boolean ;
onToggle : () => void ;
};
const FocusModeButton = React . memo ( function FocusModeButton ( props : FocusModeButtonProps ) {
const { footerIconButtonClass , iconSizeClass , isExpandedInput , onToggle } = props ;
2026-04-26 14:03:39 +03:00
const { t } = useI18n ();
2026-04-04 02:19:55 +03:00
return (
2026-04-30 22:41:33 +03:00
< Tooltip >
2026-04-04 02:19:55 +03:00
< TooltipTrigger asChild >
< button
type = "button"
className = { cn (
footerIconButtonClass ,
'rounded-md' ,
isExpandedInput
? 'text-primary'
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
)}
onMouseDown = {( event ) => {
event . preventDefault ();
}}
onClick = { onToggle }
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.focusMode.toggleAria' )}
2026-04-04 02:19:55 +03:00
aria-pressed = { isExpandedInput }
>
2026-05-13 13:26:15 +03:00
< Icon name = "fullscreen" className = { cn ( iconSizeClass )} />
2026-04-04 02:19:55 +03:00
</ button >
</ TooltipTrigger >
< TooltipContent side = "top" sideOffset = { 8 }>
< div className = "flex flex-col gap-0.5 text-center" >
2026-04-26 14:03:39 +03:00
< span >{ t ( 'chat.chatInput.focusMode.label' )}</ span >
2026-04-04 02:19:55 +03:00
< span className = "font-mono opacity-60" >
{ isMacOS () ? '⌘⇧E' : 'Ctrl+Shift+E' }
</ span >
</ div >
</ TooltipContent >
</ Tooltip >
);
});
type ComposerActionButtonsProps = {
isMobile : boolean ;
footerIconButtonClass : string ;
sendIconSizeClass : string ;
stopIconSizeClass : string ;
canSend : boolean ;
canAbort : boolean ;
hasContent : boolean ;
currentSessionId : string | null ;
newSessionDraftOpen : boolean ;
onPrimaryAction : () => void ;
onQueueMessage : () => void ;
onAbort : () => void ;
};
const ComposerActionButtons = React . memo ( function ComposerActionButtons ( props : ComposerActionButtonsProps ) {
const {
isMobile ,
footerIconButtonClass ,
sendIconSizeClass ,
stopIconSizeClass ,
canSend ,
canAbort ,
hasContent ,
currentSessionId ,
newSessionDraftOpen ,
onPrimaryAction ,
onQueueMessage ,
onAbort ,
} = props ;
2026-04-26 14:03:39 +03:00
const { t } = useI18n ();
2026-04-04 02:19:55 +03:00
const sendButton = (
< button
type = { isMobile ? 'button' : 'submit' }
disabled = { ! canSend || ( ! currentSessionId && ! newSessionDraftOpen )}
onClick = {( event ) => {
if ( ! isMobile ) {
return ;
}
event . preventDefault ();
onPrimaryAction ();
}}
className = { cn (
footerIconButtonClass ,
canSend && ( currentSessionId || newSessionDraftOpen )
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.actions.sendMessageAria' )}
2026-04-04 02:19:55 +03:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "send-plane-2" className = { cn ( sendIconSizeClass )} />
2026-04-04 02:19:55 +03:00
</ button >
);
if ( ! canAbort ) {
return sendButton ;
}
return (
< div className = "relative" >
{ hasContent ? (
< button
type = "button"
disabled = { ! currentSessionId }
onClick = {( event ) => {
if ( isMobile ) {
event . preventDefault ();
}
onQueueMessage ();
}}
className = { cn (
footerIconButtonClass ,
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1' ,
currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30'
)}
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.actions.queueMessageAria' )}
2026-04-04 02:19:55 +03:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "send-plane-2" className = { cn ( sendIconSizeClass , '-rotate-90' )} />
2026-04-04 02:19:55 +03:00
</ button >
) : null }
< button
type = "button"
onClick = { onAbort }
className = { cn (
footerIconButtonClass ,
'text-[var(--status-error)] hover:text-[var(--status-error)]'
)}
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.actions.stopGeneratingAria' )}
2026-04-04 02:19:55 +03:00
>
< StopIcon className = { cn ( stopIconSizeClass )} />
</ button >
</ div >
);
}, ( prev , next ) => (
prev . isMobile === next . isMobile
&& prev . footerIconButtonClass === next . footerIconButtonClass
&& prev . sendIconSizeClass === next . sendIconSizeClass
&& prev . stopIconSizeClass === next . stopIconSizeClass
&& prev . canSend === next . canSend
&& prev . canAbort === next . canAbort
&& prev . hasContent === next . hasContent
&& prev . currentSessionId === next . currentSessionId
&& prev . newSessionDraftOpen === next . newSessionDraftOpen
2026-05-08 08:36:35 -04:00
&& prev . onPrimaryAction === next . onPrimaryAction
&& prev . onQueueMessage === next . onQueueMessage
&& prev . onAbort === next . onAbort
2026-04-04 02:19:55 +03:00
));
2026-03-17 13:18:54 +02:00
const appendWithLineBreaks = ( base : string , next : string ) : string => {
const separator = ! base
? ''
: base . endsWith ( '\n\n' )
? ''
: base . endsWith ( '\n' )
? '\n'
: '\n\n' ;
const nextWithTrailingBreaks = next . endsWith ( '\n\n' )
? next
: next.endsWith ( '\n' )
? ` ${ next } \ n`
: ` ${ next } \ n \ n` ;
return ` ${ base }${ separator }${ nextWithTrailingBreaks } ` ;
};
2026-03-23 23:51:55 +02:00
const appendInlineText = ( base : string , next : string ) : string => {
const nextTrimmed = next . trim ();
if ( ! nextTrimmed ) {
return base ;
}
if ( ! base ) {
return ` ${ nextTrimmed } ` ;
}
const separator = /[\s\n]$/ . test ( base ) ? '' : ' ' ;
return ` ${ base }${ separator }${ nextTrimmed } ` ;
};
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-02-23 05:04:25 +07:00
type AutocompleteOverlayPosition = {
top : number ;
left : number ;
place : 'above' | 'below' ;
maxHeight : number ;
};
// Per-session draft key — preserves in-progress messages across project switches
const getDraftKey = ( sessionId : string | null ) : string =>
`openchamber_chat_input_draft_ ${ sessionId ?? 'new' } ` ;
2026-02-06 01:14:09 -08:00
2026-02-23 05:04:25 +07:00
// Helper to safely read from localStorage for a given session
const getStoredDraft = ( sessionId : string | null ) : string => {
2026-02-06 01:14:09 -08:00
try {
2026-02-23 05:04:25 +07:00
return localStorage . getItem ( getDraftKey ( sessionId )) ?? '' ;
2026-02-06 01:14:09 -08:00
} catch {
return '' ;
}
};
2026-02-23 05:04:25 +07:00
// Helper to safely write/clear a per-session draft
const saveStoredDraft = ( sessionId : string | null , draft : string ) : void => {
try {
if ( draft ) {
localStorage . setItem ( getDraftKey ( sessionId ), draft );
} else {
localStorage . removeItem ( getDraftKey ( sessionId ));
}
} catch {
// Ignore localStorage errors
}
};
2026-04-22 01:31:14 +08:00
// Per-session confirmed mentions key — tracks which @mentions are confirmed (blue) vs plain text
const getConfirmedMentionsKey = ( sessionId : string | null ) : string =>
`openchamber_chat_confirmed_mentions_ ${ sessionId ?? 'new' } ` ;
const saveConfirmedMentions = ( sessionId : string | null , mentions : Set < string >) : void => {
try {
if ( mentions . size > 0 ) {
localStorage . setItem ( getConfirmedMentionsKey ( sessionId ), JSON . stringify ([... mentions ]));
} else {
localStorage . removeItem ( getConfirmedMentionsKey ( sessionId ));
}
} catch {
// Ignore localStorage errors
}
};
const loadConfirmedMentions = ( sessionId : string | null ) : Set < string > => {
try {
const raw = localStorage . getItem ( getConfirmedMentionsKey ( sessionId ));
if ( raw ) {
const parsed = JSON . parse ( raw );
if ( Array . isArray ( parsed )) {
return new Set ( parsed . filter (( v ) : v is string => typeof v === 'string' ));
}
}
} catch {
// Ignore localStorage errors
}
return new Set ();
};
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-02-23 05:04:25 +07:00
// Track initial session ID (captured at mount time for draft restoration)
const initialSessionIdRef = React . useRef < string | null >( null );
2026-02-06 01:14:09 -08:00
const [ message , setMessage ] = React . useState (() => {
2026-02-23 05:04:25 +07:00
// Read per-session draft at mount time using the current session from the store
2026-03-31 18:47:00 +03:00
const sessionId = useSessionUIStore . getState (). currentSessionId ;
2026-02-23 05:04:25 +07:00
initialSessionIdRef . current = sessionId ;
const draft = getStoredDraft ( sessionId );
2026-02-06 01:14:09 -08:00
if ( draft ) {
initialDraftRef . current = draft ;
}
return draft ;
});
2026-04-22 01:31:14 +08:00
// Restore confirmed mentions from localStorage on mount
const confirmedMentionsRef = React . useRef < Set < string >>( loadConfirmedMentions ( initialSessionIdRef . current ));
// Helper: check if a mention path looks like a file/folder (has path separators, extension, or was explicitly confirmed)
const isConfirmedFilePath = ( text : string ) : boolean =>
text . includes ( '/' ) || text . includes ( '\\' ) || text . includes ( '.' ) || confirmedMentionsRef . current . has ( text );
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 );
2025-12-07 19:32:53 +02:00
const [ showFileMention , setShowFileMention ] = React . useState ( false );
const [ mentionQuery , setMentionQuery ] = React . useState ( '' );
const [ showCommandAutocomplete , setShowCommandAutocomplete ] = React . useState ( false );
const [ commandQuery , setCommandQuery ] = React . useState ( '' );
2026-01-08 19:58:31 +02:00
const [ showSkillAutocomplete , setShowSkillAutocomplete ] = React . useState ( false );
const [ skillQuery , setSkillQuery ] = React . useState ( '' );
2026-05-21 20:00:35 +03:00
const [ showSnippetAutocomplete , setShowSnippetAutocomplete ] = React . useState ( false );
const [ snippetQuery , setSnippetQuery ] = React . useState ( '' );
2025-12-07 19:32:53 +02:00
const [ textareaSize , setTextareaSize ] = React . useState < { height : number ; maxHeight : number } | null > ( null );
2026-01-30 06:13:37 -03:00
const [ mobileControlsPanel , setMobileControlsPanel ] = React . useState < MobileControlsPanel >( null );
2026-02-06 01:14:09 -08:00
// Message history navigation state (up/down arrow to recall previous messages)
const [ historyIndex , setHistoryIndex ] = React . useState ( - 1 ); // -1 = not browsing, 0+ = index from most recent
const [ draftMessage , setDraftMessage ] = React . useState ( '' ); // Preserves input when entering history mode
2025-12-07 19:32:53 +02:00
const textareaRef = React . useRef < HTMLTextAreaElement >( null );
2026-04-22 01:31:14 +08:00
const cursorPosRef = React . useRef ( 0 );
2026-04-04 02:19:55 +03:00
const previousMessageLengthRef = React . useRef ( message . length );
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 );
const pendingDroppedAbsolutePathsRef = React . useRef < string [] >([]);
2026-02-11 19:28:22 +02:00
const canAcceptDropRef = React . useRef ( false );
2026-03-04 01:41:01 +02:00
const nativeDragInsideDropZoneRef = 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-03-20 01:01:03 +02:00
const draftPersistTimerRef = React . useRef < ReturnType < typeof setTimeout > | null >( null );
const skipNextDraftPersistRef = React . useRef ( false );
const lastPersistedDraftRef = React . useRef < Map < string , string >>( new Map ());
const currentSessionIdForDraftRef = React . useRef < string | null >( null );
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-04-23 10:55:27 +03:00
const currentDirectory = useDirectoryStore (( s ) => s . currentDirectory );
2026-05-16 21:44:37 +08:00
const currentSessionDirectoryForSync = useSessionUIStore (
React . useCallback (( s ) => currentSessionId ? s . getDirectoryForSession ( currentSessionId ) : null , [ 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-03-31 18:47:00 +03:00
const setNewSessionDraftTarget = useSessionUIStore (( s ) => s . setNewSessionDraftTarget );
const availableWorktreesByProject = useSessionUIStore (( s ) => s . availableWorktreesByProject );
const abortPromptSessionId = useSessionUIStore (( s ) => s . abortPromptSessionId );
const clearAbortPrompt = useSessionUIStore (( s ) => s . clearAbortPrompt );
const attachedFiles = useInputStore (( s ) => s . attachedFiles );
const addAttachedFile = useInputStore (( s ) => s . addAttachedFile );
const clearAttachedFiles = useInputStore (( s ) => s . clearAttachedFiles );
const saveSessionAgentSelection = useSelectionStore (( s ) => s . saveSessionAgentSelection );
const consumePendingInputText = useInputStore (( s ) => s . consumePendingInputText );
const 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-03-20 01:01:03 +02:00
const projects = useProjectsStore (( state ) => state . projects );
const activeProjectId = useProjectsStore (( state ) => state . activeProjectId );
const setActiveProjectIdOnly = useProjectsStore (( state ) => state . setActiveProjectIdOnly );
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 );
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-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-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-03-20 01:01:03 +02:00
const { git : runtimeGit } = useRuntimeAPIs ();
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 ,
);
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-04-04 02:19:55 +03:00
const composerHighlightRef = React . useRef < HTMLDivElement | null >( null );
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 : '' ,
});
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-02-23 05:04:25 +07:00
const isDesktopExpanded = isExpandedInput && ! isMobile ;
2026-04-20 15:41:15 +03:00
const chatInputRadius = '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-03-31 18:47:00 +03:00
const sendableAttachedFiles = attachedFiles ;
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 >([
'init' , 'review' , 'undo' , 'redo' , 'timeline' , 'compact' , 'summary' , 'workspace-review' ,
]);
for ( const command of availableCommands ) names . add ( command . name . toLowerCase ());
for ( const skill of availableSkills ) names . add ( skill . name . toLowerCase ());
return names ;
}, [ availableCommands , availableSkills ]);
// /command and /skill spans (primary color). Only tokens that match a known
// command/skill name are highlighted — partial/unknown tokens stay plain.
const composerCommandRanges = React . useMemo < HighlightRange [] >(() => {
if ( ! message || ! message . includes ( '/' ) || inputMode === 'shell' || knownSlashNames . size === 0 ) {
return [];
2026-03-04 01:41:01 +02:00
}
2026-05-24 17:21:11 +03:00
const ranges : HighlightRange [] = [];
const slashRegex = /(^|\s)\/([A-Za-z0-9][A-Za-z0-9_-]*)/g ;
2026-03-04 01:41:01 +02:00
let match : RegExpExecArray | null ;
2026-05-24 17:21:11 +03:00
while (( match = slashRegex . exec ( message )) !== null ) {
const name = match [ 2 ];
if ( ! knownSlashNames . has ( name . toLowerCase ())) {
2026-03-04 01:41:01 +02:00
continue ;
}
2026-05-24 17:21:11 +03:00
const slashStart = match . index + match [ 1 ]. length ;
ranges . push ({ start : slashStart , end : slashStart + 1 + name . length , style : 'mentionCommand' });
}
return ranges ;
}, [ inputMode , knownSlashNames , message ]);
// Snippet triggers (#name / #alias). Highlighted like commands once the
// trigger matches a known snippet name or alias.
const availableSnippets = useSnippetsStore (( s ) => s . snippets );
const knownSnippetTriggers = React . useMemo (() => {
const triggers = new Set < string >();
for ( const snippet of availableSnippets ) {
triggers . add ( snippet . name . toLowerCase ());
for ( const alias of snippet . aliases ?? []) triggers . add ( alias . toLowerCase ());
}
return triggers ;
}, [ availableSnippets ]);
const composerSnippetRanges = React . useMemo < HighlightRange [] >(() => {
if ( ! message || ! message . includes ( '#' ) || inputMode === 'shell' || knownSnippetTriggers . size === 0 ) {
return [];
}
const ranges : HighlightRange [] = [];
const snippetRegex = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g ;
let match : RegExpExecArray | null ;
while (( match = snippetRegex . exec ( message )) !== null ) {
const trigger = match [ 2 ];
if ( ! knownSnippetTriggers . has ( trigger . toLowerCase ())) {
2026-03-04 01:41:01 +02:00
continue ;
}
2026-05-24 17:21:11 +03:00
const hashStart = match . index + match [ 1 ]. length ;
ranges . push ({ start : hashStart , end : hashStart + 1 + trigger . length , style : 'mentionSnippet' });
2026-03-04 01:41:01 +02:00
}
2026-05-24 17:21:11 +03:00
return ranges ;
}, [ inputMode , knownSnippetTriggers , message ]);
2026-03-04 01:41:01 +02:00
2026-05-24 17:21:11 +03:00
// @mention spans (file = blue, agent = green). Computed as character ranges
// so they can be merged with markdown highlight ranges in a single overlay.
const composerMentionRanges = React . useMemo < MentionRange [] >(() => {
if ( ! message || ! message . includes ( '@' ) || inputMode === 'shell' ) {
return [];
2026-03-04 01:41:01 +02:00
}
2026-05-24 17:21:11 +03:00
const ranges : MentionRange [] = [];
2026-03-04 01:41:01 +02:00
const mentionRegex = /@([^\s]+)/g ;
let match : RegExpExecArray | null ;
while (( match = mentionRegex . exec ( message )) !== null ) {
const full = match [ 0 ];
const mention = String ( match [ 1 ] || '' ). trim (). replace ( /[),.;:!?`"'>]+$/g , '' );
const start = match . index ;
const end = start + full . length ;
const charBefore = start > 0 ? message [ start - 1 ] : null ;
const isBoundary = ! charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/ . test ( charBefore );
2026-05-24 17:21:11 +03:00
if ( ! isBoundary || mention . length === 0 ) {
continue ;
}
if ( knownAgentNames . has ( mention . toLowerCase ())) {
ranges . push ({ start , end , kind : 'agent' });
} else if ( isConfirmedFilePath ( mention )) {
ranges . push ({ start , end , kind : 'file' });
}
2026-03-04 01:41:01 +02:00
}
2026-05-24 17:21:11 +03:00
return ranges ;
}, [ inputMode , message , knownAgentNames ]);
2026-03-04 01:41:01 +02:00
2026-05-24 23:47:22 +03:00
const attachmentCitationRanges = React . useMemo < HighlightRange [] >(() => {
if ( ! message || ! message . includes ( '[' ) || inputMode === 'shell' || sendableAttachedFiles . length === 0 ) {
return [];
}
return findAttachmentCitationRanges (
message ,
sendableAttachedFiles . map (( file ) => file . filename ),
). map (( range ) => ({
... range ,
style : 'mentionFile' as const ,
}));
}, [ inputMode , message , sendableAttachedFiles ]);
2026-05-24 17:21:11 +03:00
// Combined source-mode highlight: markdown syntax + @mentions. Returns null
// when there's nothing to highlight so the overlay stays off for plain text.
const highlightedComposerContent = React . useMemo (() => {
if ( ! message || inputMode === 'shell' ) {
return null ;
2026-03-04 01:41:01 +02:00
}
2026-05-24 17:21:11 +03:00
const ranges = [
... tokenizeMarkdown ( message ),
... highlightFencedCode ( message ),
... mentionRangesToHighlightRanges ( composerMentionRanges ),
... composerCommandRanges ,
... composerSnippetRanges ,
2026-05-24 23:47:22 +03:00
... attachmentCitationRanges ,
2026-05-24 17:21:11 +03:00
];
return buildHighlightParts ( message , ranges );
2026-05-24 23:47:22 +03:00
}, [ attachmentCitationRanges , composerCommandRanges , composerSnippetRanges , composerMentionRanges , inputMode , message ]);
2026-03-04 01:41:01 +02:00
const sanitizeAttachmentsForSend = React . useCallback (
( files : 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 [] = [];
const mentionRegex = /@([^\s]+)/g ;
let match : RegExpExecArray | null ;
while (( match = mentionRegex . exec ( rawText )) !== null ) {
const rawMentionPath = match [ 1 ];
const offset = match . index ;
const original = rawText ;
const charBefore = offset > 0 ? original [ offset - 1 ] : null ;
if ( charBefore && ! /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/ . test ( charBefore )) {
continue ;
}
const mentionPath = String ( rawMentionPath || '' )
. trim ()
. replace ( /^[`"'<(]+/ , '' )
. replace ( /[),.;:!?`"'>]+$/g , '' );
if ( ! mentionPath ) {
continue ;
}
2026-04-26 16:24:07 +03:00
if ( knownAgentNamesRef . current . has ( mentionPath . toLowerCase ())) {
2026-03-04 01:41:01 +02:00
continue ;
}
2026-04-22 01:31:14 +08:00
const looksLikeFilePath = isConfirmedFilePath ( mentionPath );
2026-03-04 01:41:01 +02:00
if ( ! looksLikeFilePath ) {
continue ;
}
const normalizedMentionPath = mentionPath . replace ( /\\/g , '/' ). replace ( /^\.\// , '' ). replace ( /^\/+/ , '' );
if ( ! normalizedMentionPath ) {
continue ;
}
const serverPath = mentionPath . startsWith ( '/' )
? mentionPath . replace ( /\\/g , '/' )
: root
? ` ${ root } / ${ normalizedMentionPath } `
: null ;
if ( ! serverPath ) {
continue ;
}
const normalizedServerPath = serverPath . replace ( /\/+/g , '/' );
if ( seenPaths . has ( normalizedServerPath )) {
continue ;
}
seenPaths . add ( normalizedServerPath );
const filename = normalizedMentionPath . split ( '/' ). filter ( Boolean ). pop () || normalizedMentionPath ;
attachments . push ({
id : `inline-server- ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 , 9 ) } ` ,
file : new File ([], filename , { type : 'text/plain' }),
filename ,
mimeType : 'text/plain' ,
size : 0 ,
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 ]);
2026-02-23 05:04:25 +07:00
const [ autocompleteOverlayPosition , setAutocompleteOverlayPosition ] = React . useState < AutocompleteOverlayPosition | null >( null );
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
const queueModeEnabled = useMessageQueueStore (( state ) => state . queueModeEnabled );
const queuedMessages = useMessageQueueStore (
React . useCallback (
( state ) => {
if ( ! currentSessionId ) return EMPTY_QUEUE ;
return state . queuedMessages [ currentSessionId ] ?? EMPTY_QUEUE ;
},
[ currentSessionId ]
)
);
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
const draftCount = useInlineCommentDraftStore (
React . useCallback (
( state ) => {
const sessionKey = currentSessionId ?? ( newSessionDraftOpen ? 'draft' : '' );
if ( ! sessionKey ) return 0 ;
return ( state . drafts [ sessionKey ] ?? []). length ;
},
[ currentSessionId , newSessionDraftOpen ]
)
);
2026-04-29 17:03:38 -04:00
const draftSourceKey = useInlineCommentDraftStore (
React . useCallback (
( state ) => {
const sessionKey = currentSessionId ?? ( newSessionDraftOpen ? 'draft' : '' );
const drafts = sessionKey ? ( state . drafts [ sessionKey ] ?? []) : [];
let previewConsole = 0 ;
let previewAnnotation = 0 ;
let review = 0 ;
for ( const draft of drafts ) {
if ( draft . source === 'preview-console' ) previewConsole += 1 ;
else if ( draft . source === 'preview-annotation' ) previewAnnotation += 1 ;
else review += 1 ;
}
return ` ${ previewConsole } : ${ previewAnnotation } : ${ review } ` ;
},
[ currentSessionId , newSessionDraftOpen ]
)
);
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-04-29 17:03:38 -04:00
const [ previewConsoleCount , previewAnnotationCount , reviewCount ] = draftSourceKey . split ( ':' ). map (( entry ) => Number ( entry ) || 0 );
const removePreviewDrafts = React . useCallback (( source : 'preview-console' | 'preview-annotation' ) => {
const sessionKey = currentSessionId ?? ( newSessionDraftOpen ? 'draft' : '' );
if ( ! sessionKey ) return ;
const drafts = useInlineCommentDraftStore . getState (). drafts [ sessionKey ] ?? [];
for ( const draft of drafts ) {
if ( draft . source === source ) {
removeInlineCommentDraft ( sessionKey , draft . id );
}
}
}, [ currentSessionId , newSessionDraftOpen , 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.
const userMessageHistory = 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 (() => {
currentSessionIdForDraftRef . current = currentSessionId ;
}, [ currentSessionId ]);
const persistDraftImmediately = React . useCallback (( sessionId : string | null , draft : string ) => {
const key = getDraftKey ( sessionId );
const lastPersisted = lastPersistedDraftRef . current . get ( key );
if ( lastPersisted === draft ) {
return ;
}
saveStoredDraft ( sessionId , draft );
2026-04-22 01:31:14 +08:00
// Only persist confirmed mentions that are actually present in the draft text
const activeMentions = new Set < string >();
for ( const mention of confirmedMentionsRef . current ) {
if ( draft . includes ( `@ ${ mention } ` )) {
activeMentions . add ( mention );
}
}
confirmedMentionsRef . current = activeMentions ;
saveConfirmedMentions ( sessionId , activeMentions );
2026-03-20 01:01:03 +02:00
lastPersistedDraftRef . current . set ( key , draft );
}, []);
const clearPendingDraftPersist = React . useCallback (() => {
if ( ! draftPersistTimerRef . current ) {
return ;
}
clearTimeout ( draftPersistTimerRef . current );
draftPersistTimerRef . current = null ;
}, []);
2026-02-06 01:14:09 -08:00
// Handle initial draft restoration and text selection
const hasHandledInitialDraftRef = React . useRef ( false );
React . useEffect (() => {
if ( hasHandledInitialDraftRef . current ) return ;
hasHandledInitialDraftRef . current = true ;
const draft = initialDraftRef . current ;
if ( ! draft ) return ;
if ( ! persistChatDraft ) {
// Setting disabled - clear the restored draft
setMessage ( '' );
try {
2026-02-23 05:04:25 +07:00
localStorage . removeItem ( getDraftKey ( initialSessionIdRef . current ));
2026-02-06 01:14:09 -08:00
} catch {
// Ignore
}
} else {
// Setting enabled - select all text
requestAnimationFrame (() => {
textareaRef . current ? . select ();
});
}
}, [ persistChatDraft ]);
2026-02-23 05:04:25 +07:00
// Handle session switching: save draft for old session, restore draft for new session
2026-02-06 01:14:09 -08:00
const prevSessionIdRef = React . useRef ( currentSessionId );
React . useEffect (() => {
if ( prevSessionIdRef . current !== currentSessionId ) {
2026-02-23 05:04:25 +07:00
const oldSessionId = prevSessionIdRef . current ;
2026-02-06 01:14:09 -08:00
prevSessionIdRef . current = currentSessionId ;
2026-02-18 20:08:42 +02:00
setInputMode ( 'normal' );
2026-03-20 01:01:03 +02:00
clearPendingDraftPersist ();
skipNextDraftPersistRef . current = true ;
2026-02-23 05:04:25 +07:00
if ( persistChatDraft ) {
// Save current draft for the session we're leaving
2026-03-20 01:01:03 +02:00
persistDraftImmediately ( oldSessionId , messageRef . current );
2026-02-23 05:04:25 +07:00
// Restore draft for the session we're entering
const newDraft = getStoredDraft ( currentSessionId );
setMessage ( newDraft );
2026-04-22 01:31:14 +08:00
confirmedMentionsRef . current = loadConfirmedMentions ( currentSessionId );
2026-02-23 05:04:25 +07:00
if ( newDraft ) {
requestAnimationFrame (() => {
textareaRef . current ? . select ();
});
}
} else {
// Persist disabled: clear input without saving
2026-02-06 01:14:09 -08:00
setMessage ( '' );
2026-04-22 01:31:14 +08:00
confirmedMentionsRef . current = new Set ();
2026-02-06 01:14:09 -08:00
}
}
2026-03-20 01:01:03 +02:00
}, [ clearPendingDraftPersist , currentSessionId , persistChatDraft , persistDraftImmediately ]);
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
textareaRef . current ? . focus ({ preventScroll : true });
} else {
textareaRef . current ? . focus ();
}
});
}
prevNewSessionDraftOpenRef . current = newSessionDraftOpen ;
}, [ newSessionDraftOpen , isMobile ]);
2026-02-23 05:04:25 +07:00
// Persist chat input draft to localStorage per session (only if setting enabled)
2026-02-06 01:14:09 -08:00
React . useEffect (() => {
if ( ! persistChatDraft ) {
2026-03-20 01:01:03 +02:00
clearPendingDraftPersist ();
persistDraftImmediately ( currentSessionId , '' );
2026-02-06 01:14:09 -08:00
return ;
}
2026-03-20 01:01:03 +02:00
if ( skipNextDraftPersistRef . current ) {
skipNextDraftPersistRef . current = false ;
return ;
}
clearPendingDraftPersist ();
const draftSnapshot = message ;
const sessionSnapshot = currentSessionId ;
draftPersistTimerRef . current = setTimeout (() => {
draftPersistTimerRef . current = null ;
persistDraftImmediately ( sessionSnapshot , draftSnapshot );
}, CHAT_DRAFT_PERSIST_DEBOUNCE_MS );
return () => {
clearPendingDraftPersist ();
};
}, [ clearPendingDraftPersist , currentSessionId , message , persistChatDraft , persistDraftImmediately ]);
React . useEffect (() => {
return () => {
clearPendingDraftPersist ();
if ( persistChatDraft ) {
persistDraftImmediately ( currentSessionIdForDraftRef . current , messageRef . current );
}
};
}, [ clearPendingDraftPersist , persistChatDraft , persistDraftImmediately ]);
2026-02-06 01:14:09 -08:00
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-01-30 06:13:37 -03:00
const handleOpenMobilePanel = React . useCallback (( panel : MobileControlsPanel ) => {
if ( ! isMobile ) {
return ;
}
textareaRef . current ? . blur ();
requestAnimationFrame (() => {
setMobileControlsPanel ( panel );
});
}, [ 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 (() => {
textareaRef . current ? . focus ();
}, 0 );
}
}
}, [ pendingInputText , consumePendingInputText ]);
2026-04-04 02:19:55 +03:00
const hasContent = message . trim (). length > 0 || sendableAttachedFiles . 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 (() => {
const currentMessage = textareaRef . current ? . value ?? message ;
return {
message : currentMessage ,
hasContent : currentMessage.trim (). length > 0 || sendableAttachedFiles . length > 0 || hasDrafts ,
};
}, [ hasDrafts , message , sendableAttachedFiles . length ]);
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-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 ();
if ( ! inputSnapshot . hasContent || ! currentSessionId ) return ;
2025-12-07 19:32:53 +02:00
2026-02-05 03:14:26 +02:00
const drafts = consumeDrafts ( currentSessionId );
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-03-04 01:41:01 +02:00
const attachmentsToQueue = sanitizeAttachmentsForSend ( sendableAttachedFiles );
2025-12-07 19:32:53 +02:00
2025-12-29 02:16:42 +02:00
addToQueue ( currentSessionId , {
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 ) {
textareaRef . current ? . focus ();
2025-12-07 19:32:53 +02:00
}
2026-05-01 04:26:14 -07:00
}, [ getCurrentInputSnapshot , currentSessionId , sendableAttachedFiles , 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 (() => {
textareaRef . current ? . focus ();
}, 0 );
}, []);
2026-05-27 17:13:45 +03:00
const handleQueuedMessageSend = React . useCallback (( messageId : string ) => {
void handleSubmitRef . current ({ queuedOnly : true , queuedMessageId : messageId });
}, []);
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-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-05-01 04:26:14 -07:00
const inputSnapshot = getCurrentInputSnapshot ();
2026-05-27 17:13:45 +03:00
const queuedMessagesToSend = queuedMessageId
? queuedMessages . filter (( message ) => message . id === queuedMessageId )
: queuedMessages ;
2025-12-29 02:16:42 +02:00
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 ;
}
2025-12-29 02:16:42 +02:00
// Build the primary message (first part) and additional parts
let primaryText = '' ;
let primaryAttachments : AttachedFile [] = [];
let agentMentionName : string | undefined ;
2026-02-07 01:33:17 -08:00
const additionalParts : Array < { text : string ; attachments? : AttachedFile []; synthetic? : boolean } > = [];
2026-05-17 00:47:51 +03:00
const availableSkillNames = new Set ( useSkillsStore . getState (). skills . map (( skill ) => skill . name ));
const mentionedSkillNames : string [] = [];
const addMentionedSkills = ( text : string ) => {
for ( const name of collectInlineSkillMentions ( text , availableSkillNames )) {
if ( ! mentionedSkillNames . includes ( name )) mentionedSkillNames . push ( name );
}
};
2026-02-07 01:33:17 -08:00
// Consume any pending synthetic parts (from conflict resolution, etc.)
const syntheticParts = consumePendingSyntheticParts ();
2025-12-29 02:16:42 +02:00
// Process queued messages first
2026-05-27 17:13:45 +03:00
for ( let i = 0 ; i < queuedMessagesToSend . length ; i ++ ) {
const queuedMsg = queuedMessagesToSend [ i ];
2025-12-29 02:16:42 +02:00
const { sanitizedText , mention } = parseAgentMentions ( queuedMsg . content , agents );
2026-03-04 01:41:01 +02:00
const { sanitizedText : queuedText , attachments : mentionAttachments } = extractInlineFileMentions ( sanitizedText );
2026-05-17 00:47:51 +03:00
addMentionedSkills ( queuedText );
2026-02-04 01:14:10 -08:00
2025-12-29 02:16:42 +02:00
// Use agent mention from first message that has one
if ( ! agentMentionName && mention ? . name ) {
agentMentionName = mention . name ;
}
2025-12-07 19:32:53 +02:00
2025-12-29 02:16:42 +02:00
if ( i === 0 ) {
// First queued message becomes primary
2026-03-04 01:41:01 +02:00
primaryText = queuedText ;
primaryAttachments = [
... sanitizeAttachmentsForSend ( queuedMsg . attachments ),
... mentionAttachments ,
];
2025-12-29 02:16:42 +02:00
} else {
// Subsequent queued messages become additional parts
2026-03-04 01:41:01 +02:00
const queuedAttachments = sanitizeAttachmentsForSend ( queuedMsg . attachments );
2025-12-29 02:16:42 +02:00
additionalParts . push ({
2026-03-04 01:41:01 +02:00
text : queuedText ,
attachments : [... queuedAttachments , ... mentionAttachments ],
2025-12-29 02:16:42 +02:00
});
}
2025-12-07 19:32:53 +02:00
}
2026-02-11 19:28:22 +02:00
// Add current input (skip for queued-only auto-send)
2026-05-01 04:26:14 -07:00
if ( ! queuedOnly && inputSnapshot . hasContent ) {
const messageToSend = inputSnapshot . message . replace ( /^\n+|\n+$/g , '' );
2025-12-29 02:16:42 +02:00
const { sanitizedText , mention } = parseAgentMentions ( messageToSend , agents );
2026-03-04 01:41:01 +02:00
const { sanitizedText : messageText , attachments : mentionAttachments } = extractInlineFileMentions ( sanitizedText );
const attachmentsToSend = sanitizeAttachmentsForSend ( sendableAttachedFiles );
2026-05-17 00:47:51 +03:00
addMentionedSkills ( messageText );
2025-12-29 02:16:42 +02:00
if ( ! agentMentionName && mention ? . name ) {
agentMentionName = mention . name ;
}
2026-05-27 17:13:45 +03:00
if ( queuedMessagesToSend . length === 0 ) {
2025-12-29 02:16:42 +02:00
// No queue - current input is primary
2026-03-04 01:41:01 +02:00
primaryText = messageText ;
primaryAttachments = [... attachmentsToSend , ... mentionAttachments ];
2025-12-29 02:16:42 +02:00
} else {
// Has queue - current input is additional part
additionalParts . push ({
2026-03-04 01:41:01 +02:00
text : messageText ,
attachments : [... attachmentsToSend , ... mentionAttachments ],
2025-12-29 02:16:42 +02:00
});
}
}
2026-02-05 03:14:26 +02:00
const sessionKey = currentSessionId ?? ( newSessionDraftOpen ? 'draft' : null );
let drafts : InlineCommentDraft [] = [];
2026-02-11 19:28:22 +02:00
if ( ! queuedOnly && sessionKey ) {
2026-02-05 03:14:26 +02:00
drafts = consumeDrafts ( sessionKey );
}
if ( drafts . length > 0 ) {
2026-05-27 17:13:45 +03:00
if ( queuedMessagesToSend . length === 0 ) {
2026-02-05 03:14:26 +02:00
primaryText = appendInlineComments ( primaryText , drafts );
} else if ( additionalParts . length > 0 ) {
const lastPart = additionalParts [ additionalParts . length - 1 ];
lastPart . text = appendInlineComments ( lastPart . text , drafts );
} else {
primaryText = appendInlineComments ( primaryText , drafts );
}
}
2026-02-07 01:33:17 -08:00
// Add synthetic parts (from conflict resolution, etc.)
if ( syntheticParts && syntheticParts . length > 0 ) {
for ( const part of syntheticParts ) {
additionalParts . push ({
text : part.text ,
synthetic : true ,
});
}
}
2026-03-03 00:20:15 +02:00
// Add linked issue as synthetic part (only the parts with synthetic: true)
// The text part (synthetic: false) is completely dropped per requirements
2026-03-04 01:41:01 +02:00
if ( linkedIssue ) {
2026-03-03 00:20:15 +02:00
additionalParts . push ({
text : linkedIssue.contextText ,
synthetic : true ,
});
}
2026-03-04 01:41:01 +02:00
if ( linkedPr ) {
additionalParts . push ({
text : linkedPr.instructionsText ,
synthetic : true ,
});
additionalParts . push ({
text : linkedPr.contextText ,
synthetic : true ,
});
}
2026-05-17 00:47:51 +03:00
const skillMentionInstruction = buildSkillMentionInstruction ( mentionedSkillNames );
if ( skillMentionInstruction ) {
additionalParts . push ({
text : skillMentionInstruction ,
synthetic : true ,
});
}
2026-05-17 15:25:59 +03:00
if ( ! primaryText && primaryAttachments . length === 0 && additionalParts . length === 0 ) return ;
2025-12-29 02:16:42 +02:00
// Clear queue and input
2026-05-27 17:13:45 +03:00
if ( currentSessionId && queuedMessageId ) {
removeFromQueue ( currentSessionId , queuedMessageId );
} else if ( currentSessionId && hasQueuedMessages ) {
2025-12-29 02:16:42 +02:00
clearQueue ( currentSessionId );
}
2026-02-11 19:28:22 +02:00
if ( ! queuedOnly ) {
setMessage ( '' );
2026-04-22 01:31:14 +08:00
confirmedMentionsRef . current . clear ();
2026-02-23 05:04:25 +07:00
// Clear per-session draft on submit
saveStoredDraft ( currentSessionId , '' );
2026-04-22 01:31:14 +08:00
saveConfirmedMentions ( currentSessionId , confirmedMentionsRef . current );
2026-02-11 19:28:22 +02:00
// Reset message history navigation state
setHistoryIndex ( - 1 );
setDraftMessage ( '' );
if ( attachedFiles . length > 0 ) {
clearAttachedFiles ();
}
2026-02-23 05:04:25 +07:00
// Close expanded input overlay when submitting
setExpandedInput ( false );
2025-12-29 02:16:42 +02:00
}
2025-12-07 19:32:53 +02:00
2025-12-18 11:20:37 +02:00
if ( isMobile ) {
textareaRef . current ? . blur ();
}
2026-02-18 20:08:42 +02:00
// Handle local slash commands only in normal mode
2025-12-29 02:16:42 +02:00
const normalizedCommand = primaryText . trimStart ();
2026-02-18 20:08:42 +02:00
if ( inputMode === 'normal' && normalizedCommand . startsWith ( '/' )) {
2025-12-29 02:16:42 +02:00
const commandName = normalizedCommand
. slice ( 1 )
. trim ()
. split ( /\s+/ )[ 0 ]
? . toLowerCase ();
2026-01-02 18:33:35 -05:00
if ( commandName === 'undo' && currentSessionId ) {
2026-03-31 18:47:00 +03:00
await useSessionUIStore . getState (). handleSlashUndo ( currentSessionId );
2026-05-08 14:20:16 +03:00
scrollToBottom ? .();
2026-03-31 18:47:00 +03:00
return ;
2026-01-02 18:33:35 -05:00
}
else if ( commandName === 'redo' && currentSessionId ) {
2026-03-31 18:47:00 +03:00
await useSessionUIStore . getState (). handleSlashRedo ( currentSessionId );
2026-05-08 14:20:16 +03:00
scrollToBottom ? .();
2026-03-31 18:47:00 +03:00
return ;
2026-01-02 18:33:35 -05:00
}
2026-05-05 16:21:06 +08:00
else if ( commandName === 'timeline' && currentSessionId ) {
setTimelineDialogOpen ( true );
return ;
}
2026-03-31 18:47:00 +03:00
else if ( commandName === 'compact' && currentSessionId ) {
2026-04-17 23:48:39 +08:00
try {
2026-04-22 21:03:02 +03:00
await sessionActions . waitForConnectionOrThrow ();
2026-04-17 23:48:39 +08:00
const { opencodeClient } = await import ( '@/lib/opencode/client' );
const sdk = opencodeClient . getSdkClient ();
const configState = useConfigStore . getState ();
await sdk . session . summarize ({
sessionID : currentSessionId ,
modelID : configState.currentModelId || '' ,
providerID : configState.currentProviderId || '' ,
});
} catch ( error ) {
2026-04-26 14:03:39 +03:00
toast . error ( error instanceof Error ? error.message : t ( 'chat.chatInput.toast.compactFailed' ));
2026-04-17 23:48:39 +08:00
}
2026-03-31 18:47:00 +03:00
return ;
2026-01-02 18:33:35 -05:00
}
2026-04-22 21:56:35 +03:00
else if ( commandName === 'summary' && currentSessionId ) {
try {
await sessionActions . waitForConnectionOrThrow ();
// Everything after `/summary ` is an optional topic hint
// the user wants the summary focused on.
const topic = normalizedCommand . replace ( /^\/summary\b/i , '' ). trim ();
const topicLine = topic ? ` focused on: ${ topic } ` : '' ;
const topicBlock = topic
? `The user asked you to focus this summary on: ${ topic } . Prioritize that topic; mention unrelated threads only in passing.`
: '' ;
const visibleText = await renderMagicPrompt ( 'session.summary.visible' , { topic_line : topicLine });
const instructionsText = await renderMagicPrompt ( 'session.summary.instructions' , { topic_block : topicBlock });
await sendMessage (
visibleText ,
2026-05-27 17:13:45 +03:00
providerIdToSend ,
modelIdToSend ,
agentNameToSend ,
2026-04-22 21:56:35 +03:00
[],
agentMentionName ,
[{ text : instructionsText , synthetic : true }],
2026-05-27 17:13:45 +03:00
variantToSend ,
2026-04-22 21:56:35 +03:00
inputMode ,
);
2026-05-08 14:20:16 +03:00
scrollToBottom ? .();
2026-04-22 21:56:35 +03:00
} catch ( error ) {
2026-04-26 14:03:39 +03:00
toast . error ( error instanceof Error ? error.message : t ( 'chat.chatInput.toast.summaryFailed' ));
2026-04-22 21:56:35 +03:00
}
return ;
}
2026-04-26 23:15:35 +03:00
else if ( commandName === 'workspace-review' && ( currentSessionId || newSessionDraftOpen )) {
2026-04-23 10:45:41 +03:00
try {
await sessionActions . waitForConnectionOrThrow ();
const visibleText = await renderMagicPrompt ( 'session.review.visible' );
const instructionsText = await renderMagicPrompt ( 'session.review.instructions' );
await sendMessage (
visibleText ,
2026-05-27 17:13:45 +03:00
providerIdToSend ,
modelIdToSend ,
agentNameToSend ,
2026-04-23 10:45:41 +03:00
[],
agentMentionName ,
[{ text : instructionsText , synthetic : true }],
2026-05-27 17:13:45 +03:00
variantToSend ,
2026-04-23 10:45:41 +03:00
inputMode ,
);
2026-05-08 14:20:16 +03:00
scrollToBottom ? .();
2026-04-23 10:45:41 +03:00
} catch ( error ) {
2026-04-26 14:03:39 +03:00
toast . error ( error instanceof Error ? error.message : t ( 'chat.chatInput.toast.reviewFailed' ));
2026-04-23 10:45:41 +03:00
}
return ;
}
2025-12-29 02:16:42 +02:00
}
2026-05-01 01:27:31 +03:00
const currentSessionDirectory = currentSessionId
? useSessionUIStore . getState (). getDirectoryForSession ( currentSessionId ) || currentDirectory
: currentDirectory ;
const shouldAddResponseStyle = newSessionDraftOpen || ( currentSessionId ? ! hasUserMessages ( currentSessionId , currentSessionDirectory ) : false );
if ( shouldAddResponseStyle ) {
const responseStyleInstruction = await fetchResponseStyleInstruction (). catch (() => null );
if ( responseStyleInstruction ) {
additionalParts . push ({
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-02-18 20:08:42 +02:00
inputMode
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 );
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 ;
}
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 ) {
textareaRef . current ? . focus ();
}
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-02-01 22:43:46 +02:00
// Primary action for send button - respects queue mode setting
const handlePrimaryAction = React . useCallback (() => {
2026-05-01 04:26:14 -07:00
const inputSnapshot = getCurrentInputSnapshot ();
const canQueue = inputMode === 'normal' && inputSnapshot . hasContent && currentSessionId && sessionPhase !== 'idle' ;
2026-02-01 22:43:46 +02:00
if ( queueModeEnabled && canQueue ) {
handleQueueMessage ();
} else {
2026-02-05 01:59:49 +02:00
void handleSubmitRef . current ();
2026-02-01 22:43:46 +02:00
}
2026-05-01 04:26:14 -07:00
}, [ inputMode , getCurrentInputSnapshot , currentSessionId , sessionPhase , queueModeEnabled , handleQueueMessage ]);
2025-12-29 02:16:42 +02:00
2025-12-07 19:32:53 +02:00
const handleKeyDown = ( e : React.KeyboardEvent < HTMLTextAreaElement >) => {
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-03-04 01:41:01 +02:00
if (( e . key === 'Backspace' || e . key === 'Delete' ) && ! e . metaKey && ! e . ctrlKey && ! e . altKey ) {
const textarea = textareaRef . current ;
const selectionStart = textarea ? . selectionStart ?? message . length ;
const selectionEnd = textarea ? . selectionEnd ?? message . length ;
const hasCollapsedSelection = selectionStart === selectionEnd ;
if ( hasCollapsedSelection ) {
const probeIndex = e . key === 'Backspace' ? selectionStart - 1 : selectionStart ;
if ( probeIndex >= 0 && probeIndex < message . length ) {
let tokenStart = probeIndex ;
while ( tokenStart > 0 && ! /\s/ . test ( message [ tokenStart - 1 ])) {
tokenStart -= 1 ;
}
let tokenEnd = probeIndex + 1 ;
while ( tokenEnd < message . length && ! /\s/ . test ( message [ tokenEnd ])) {
tokenEnd += 1 ;
}
const token = message . slice ( tokenStart , tokenEnd );
2026-04-22 01:31:14 +08:00
const mentionContent = token . slice ( 1 );
2026-03-04 01:41:01 +02:00
const looksLikeFileMention = FILE_MENTION_TOKEN . test ( token )
2026-04-26 16:24:07 +03:00
&& ! knownAgentNamesRef . current . has ( mentionContent . toLowerCase ())
2026-04-22 01:31:14 +08:00
&& isConfirmedFilePath ( mentionContent );
2026-03-04 01:41:01 +02:00
if ( looksLikeFileMention ) {
2026-04-22 01:31:14 +08:00
confirmedMentionsRef . current . delete ( mentionContent );
2026-03-04 01:41:01 +02:00
const removeUntil = message [ tokenEnd ] === ' ' ? tokenEnd + 1 : tokenEnd ;
const nextMessage = ` ${ message . slice ( 0 , tokenStart ) }${ message . slice ( removeUntil ) } ` ;
e . preventDefault ();
setMessage ( nextMessage );
requestAnimationFrame (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = tokenStart ;
textareaRef . current . selectionEnd = tokenStart ;
}
adjustTextareaHeight ();
});
updateAutocompleteState ( nextMessage , tokenStart );
return ;
}
}
}
}
2026-02-18 20:08:42 +02:00
if ( showCommandAutocomplete && 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-02-18 20:08:42 +02:00
commandRef . current . handleKeyDown ( e . key );
2025-12-07 19:32:53 +02:00
return ;
}
}
2026-01-08 19:58:31 +02:00
if ( showSkillAutocomplete && skillRef . current ) {
if ( e . key === 'Enter' || e . key === 'ArrowUp' || e . key === 'ArrowDown' || e . key === 'Escape' || e . key === 'Tab' ) {
e . preventDefault ();
skillRef . current . handleKeyDown ( e . key );
return ;
}
}
2026-05-21 20:00:35 +03:00
if ( showSnippetAutocomplete && snippetRef . current ) {
if ( e . key === 'Enter' || e . key === 'ArrowUp' || e . key === 'ArrowDown' || e . key === 'Escape' || e . key === 'Tab' ) {
e . preventDefault ();
snippetRef . current . handleKeyDown ( e . key );
return ;
}
}
2025-12-07 19:32:53 +02:00
if ( showFileMention && mentionRef . current ) {
if ( e . key === 'Enter' || e . key === 'ArrowUp' || e . key === 'ArrowDown' || e . key === 'Escape' || e . key === 'Tab' ) {
e . preventDefault ();
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-05-21 20:00:35 +03:00
if ( cycleAgentDirection !== 0 && ! showCommandAutocomplete && ! showSkillAutocomplete && ! showSnippetAutocomplete && ! showFileMention ) {
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
// ArrowUp: only when cursor at start (position 0) or input is empty
// ArrowDown: also works when cursor at end (to cycle forward through history)
2026-05-21 20:00:35 +03:00
const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showSnippetAutocomplete || showFileMention ;
2026-02-06 01:14:09 -08:00
const cursorAtStart = textareaRef . current ? . selectionStart === 0 && textareaRef . current ? . selectionEnd === 0 ;
const cursorAtEnd = textareaRef . current ? . selectionStart === message . length && textareaRef . current ? . selectionEnd === message . length ;
const canNavigateHistoryUp = ! isAnyAutocompleteOpen && ( message . length === 0 || cursorAtStart );
const canNavigateHistoryDown = ! isAnyAutocompleteOpen && ( message . length === 0 || cursorAtEnd );
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 ) {
const ta = textareaRef . current ;
const selStart = ta ? . selectionStart ?? - 1 ;
const selEnd = ta ? . selectionEnd ?? - 1 ;
if ( ta && selStart >= 0 ) {
const applyEdit = ( next : string , caretStart : number , caretEnd : number ) => {
e . preventDefault ();
setMessage ( next );
requestAnimationFrame (() => {
const current = textareaRef . current ;
if ( current ) {
current . selectionStart = caretStart ;
current . selectionEnd = caretEnd ;
}
adjustTextareaHeight ();
});
updateAutocompleteState ( next , caretEnd );
};
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
const WRAP_PAIRS : Record < 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-02-06 01:14:09 -08:00
if ( e . key === 'ArrowUp' && canNavigateHistoryUp && userMessageHistory . length > 0 ) {
e . preventDefault ();
if ( historyIndex === - 1 ) {
// Entering history mode - save current input as draft
setDraftMessage ( message );
setHistoryIndex ( 0 );
setMessage ( userMessageHistory [ 0 ]);
} else if ( historyIndex < userMessageHistory . length - 1 ) {
// Navigate to older message
const newIndex = historyIndex + 1 ;
setHistoryIndex ( newIndex );
setMessage ( userMessageHistory [ newIndex ]);
}
// Move cursor to start after history navigation
requestAnimationFrame (() => {
textareaRef . current ? . setSelectionRange ( 0 , 0 );
});
// If at oldest message, do nothing
return ;
}
if ( e . key === 'ArrowDown' && canNavigateHistoryDown && historyIndex >= 0 ) {
e . preventDefault ();
if ( historyIndex === 0 ) {
// Exit history mode - restore draft
setHistoryIndex ( - 1 );
setMessage ( draftMessage );
setDraftMessage ( '' );
} else {
// Navigate to newer message
const newIndex = historyIndex - 1 ;
setHistoryIndex ( newIndex );
setMessage ( userMessageHistory [ newIndex ]);
}
return ;
}
2025-12-29 02:16:42 +02:00
// Handle Enter/Ctrl+Enter based on queue mode
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
2025-12-29 02:16:42 +02:00
// Queue mode: Enter queues, Ctrl+Enter sends
// Normal mode: Enter sends, Ctrl+Enter queues
// Note: Queueing only works when there's an existing session (currentSessionId)
// For new sessions (draft), always send immediately
2026-02-18 20:08:42 +02:00
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle' ;
2026-02-04 01:14:10 -08:00
2025-12-29 02:16:42 +02:00
if ( queueModeEnabled ) {
if ( isCtrlEnter || ! canQueue ) {
// Ctrl+Enter sends, or Enter when can't queue (new session)
handleSubmit ();
} else {
// Enter queues when we have a session
handleQueueMessage ();
}
} else {
if ( isCtrlEnter && canQueue ) {
// Ctrl+Enter queues when we have a session
handleQueueMessage ();
} else {
// Enter sends
handleSubmit ();
}
}
2025-12-07 19:32:53 +02:00
}
};
2026-02-23 05:04:25 +07:00
const measureCaretInTextarea = React . useCallback (( textarea : HTMLTextAreaElement , cursorPosition : number ) => {
const doc = textarea . ownerDocument ;
const win = doc . defaultView ;
if ( ! win ) return null ;
const style = win . getComputedStyle ( textarea );
const mirror = doc . createElement ( 'div' );
const mirrorStyle = mirror . style ;
mirrorStyle . position = 'absolute' ;
mirrorStyle . visibility = 'hidden' ;
mirrorStyle . pointerEvents = 'none' ;
mirrorStyle . whiteSpace = 'pre-wrap' ;
mirrorStyle . wordWrap = 'break-word' ;
mirrorStyle . overflow = 'hidden' ;
mirrorStyle . left = '-9999px' ;
mirrorStyle . top = '0' ;
mirrorStyle . width = ` ${ textarea . clientWidth } px` ;
mirrorStyle . font = style . font ;
mirrorStyle . fontSize = style . fontSize ;
mirrorStyle . fontFamily = style . fontFamily ;
mirrorStyle . fontWeight = style . fontWeight ;
mirrorStyle . fontStyle = style . fontStyle ;
mirrorStyle . fontVariant = style . fontVariant ;
mirrorStyle . letterSpacing = style . letterSpacing ;
mirrorStyle . textTransform = style . textTransform ;
mirrorStyle . textIndent = style . textIndent ;
mirrorStyle . padding = style . padding ;
mirrorStyle . border = style . border ;
mirrorStyle . boxSizing = style . boxSizing ;
mirrorStyle . lineHeight = style . lineHeight ;
mirrorStyle . tabSize = style . tabSize ;
mirror . textContent = textarea . value . slice ( 0 , cursorPosition );
const marker = doc . createElement ( 'span' );
marker . textContent = textarea . value . slice ( cursorPosition , cursorPosition + 1 ) || ' ' ;
mirror . appendChild ( marker );
doc . body . appendChild ( mirror );
const top = marker . offsetTop ;
const left = marker . offsetLeft ;
doc . body . removeChild ( mirror );
return { top , left };
}, []);
const updateAutocompleteOverlayPosition = React . useCallback (() => {
if ( ! isDesktopExpanded ) {
setAutocompleteOverlayPosition ( null );
return ;
}
2026-05-21 20:00:35 +03:00
if ( ! showCommandAutocomplete && ! showSkillAutocomplete && ! showSnippetAutocomplete && ! showFileMention ) {
2026-02-23 05:04:25 +07:00
setAutocompleteOverlayPosition ( null );
return ;
}
const textarea = textareaRef . current ;
const container = dropZoneRef . current ;
if ( ! textarea || ! container ) return ;
const cursor = textarea . selectionStart ?? message . length ;
const caret = measureCaretInTextarea ( textarea , cursor );
if ( ! caret ) return ;
const textareaRect = textarea . getBoundingClientRect ();
const containerRect = container . getBoundingClientRect ();
const caretY = textareaRect . top - containerRect . top + ( caret . top - textarea . scrollTop );
const caretX = textareaRect . left - containerRect . left + ( caret . left - textarea . scrollLeft );
const popupMargin = 8 ;
const estimatedPopupHeight = 260 ;
const spaceAbove = caretY - popupMargin ;
const spaceBelow = containerRect . height - caretY - popupMargin ;
const place : 'above' | 'below' = spaceBelow >= estimatedPopupHeight || spaceBelow >= spaceAbove ? 'below' : 'above' ;
2026-05-21 20:00:35 +03:00
const desiredWidth = showFileMention ? 520 : showCommandAutocomplete || showSnippetAutocomplete ? 450 : 360 ;
2026-02-23 05:04:25 +07:00
const clampedLeft = Math . max (
popupMargin ,
Math . min ( caretX - 24 , containerRect . width - desiredWidth - popupMargin )
);
const maxHeight = Math . max ( 120 , Math . min ( estimatedPopupHeight , place === 'below' ? spaceBelow : spaceAbove ));
setAutocompleteOverlayPosition ({
top : place === 'below' ? caretY + 22 : caretY - 6 ,
left : clampedLeft ,
place ,
maxHeight ,
});
}, [
isDesktopExpanded ,
measureCaretInTextarea ,
message . length ,
showCommandAutocomplete ,
showFileMention ,
2026-05-21 20:00:35 +03:00
showSnippetAutocomplete ,
2026-02-23 05:04:25 +07:00
showSkillAutocomplete ,
]);
React . useLayoutEffect (() => {
updateAutocompleteOverlayPosition ();
}, [
updateAutocompleteOverlayPosition ,
message ,
showCommandAutocomplete ,
showSkillAutocomplete ,
2026-05-21 20:00:35 +03:00
showSnippetAutocomplete ,
2026-02-23 05:04:25 +07:00
showFileMention ,
isDesktopExpanded ,
]);
React . useEffect (() => {
if ( ! isDesktopExpanded ) return ;
const onResize = () => updateAutocompleteOverlayPosition ();
window . addEventListener ( 'resize' , onResize );
return () => {
window . removeEventListener ( 'resize' , onResize );
};
}, [ isDesktopExpanded , updateAutocompleteOverlayPosition ]);
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-04-04 02:19:55 +03:00
const adjustTextareaHeight = React . useCallback (( options ?: { allowShrink? : boolean }) => {
2025-12-07 19:32:53 +02:00
const textarea = textareaRef . current ;
if ( ! textarea ) {
return ;
}
2026-04-04 02:19:55 +03:00
const previousScrollTop = textarea . scrollTop ;
2026-02-23 05:04:25 +07:00
if ( isDesktopExpanded ) {
textarea . style . height = '100%' ;
textarea . style . maxHeight = 'none' ;
setTextareaSize ( null );
2026-04-04 02:19:55 +03:00
if ( textarea . scrollTop !== previousScrollTop ) {
textarea . scrollTop = previousScrollTop ;
}
2026-02-23 05:04:25 +07:00
return ;
}
2026-04-04 02:19:55 +03:00
if ( options ? . allowShrink ?? true ) {
textarea . style . height = 'auto' ;
}
2025-12-07 19:32:53 +02:00
const view = textarea . ownerDocument ? . defaultView ;
const computedStyle = view ? view . getComputedStyle ( textarea ) : null ;
const lineHeight = computedStyle ? parseFloat ( computedStyle . lineHeight ) : NaN ;
const paddingTop = computedStyle ? parseFloat ( computedStyle . paddingTop ) : NaN ;
const paddingBottom = computedStyle ? parseFloat ( computedStyle . paddingBottom ) : NaN ;
const fallbackLineHeight = 22 ;
const fallbackPadding = 16 ;
const paddingTotal = Number . isNaN ( paddingTop ) || Number . isNaN ( paddingBottom )
? fallbackPadding
: paddingTop + paddingBottom ;
const targetLineHeight = Number . isNaN ( lineHeight ) ? fallbackLineHeight : lineHeight ;
const maxHeight = targetLineHeight * MAX_VISIBLE_TEXTAREA_LINES + paddingTotal ;
const scrollHeight = textarea . scrollHeight || textarea . offsetHeight ;
const nextHeight = Math . min ( scrollHeight , maxHeight );
textarea . style . height = ` ${ nextHeight } px` ;
textarea . style . maxHeight = ` ${ maxHeight } px` ;
2026-04-04 02:19:55 +03:00
if ( textarea . scrollTop !== previousScrollTop ) {
textarea . scrollTop = previousScrollTop ;
}
2025-12-07 19:32:53 +02:00
setTextareaSize (( prev ) => {
if ( prev && prev . height === nextHeight && prev . maxHeight === maxHeight ) {
return prev ;
}
return { height : nextHeight , maxHeight };
});
2026-02-23 05:04:25 +07:00
}, [ isDesktopExpanded ]);
2025-12-07 19:32:53 +02:00
React . useLayoutEffect (() => {
2026-04-04 02:19:55 +03:00
const allowShrink = message . length < previousMessageLengthRef . current ;
previousMessageLengthRef . current = message . length ;
adjustTextareaHeight ({ allowShrink });
2025-12-07 19:32:53 +02:00
}, [ adjustTextareaHeight , message , isMobile ]);
const updateAutocompleteState = React . useCallback (( value : string , cursorPosition : number ) => {
2026-02-18 20:08:42 +02:00
if ( inputMode === 'shell' ) {
setShowCommandAutocomplete ( false );
setShowFileMention ( false );
setShowSkillAutocomplete ( false );
2026-05-21 20:00:35 +03:00
setShowSnippetAutocomplete ( false );
2026-02-18 20:08:42 +02:00
return ;
}
2025-12-07 19:32:53 +02:00
if ( value . startsWith ( '/' )) {
const firstSpace = value . indexOf ( ' ' );
const firstNewline = value . indexOf ( '\n' );
const commandEnd = Math . min (
firstSpace === - 1 ? value.length : firstSpace ,
firstNewline === - 1 ? value.length : firstNewline
);
if ( cursorPosition <= commandEnd && firstSpace === - 1 ) {
const commandText = value . substring ( 1 , commandEnd );
setCommandQuery ( commandText );
setShowCommandAutocomplete ( true );
setShowFileMention ( false );
2026-01-08 19:58:31 +02:00
setShowSkillAutocomplete ( false );
2026-05-21 20:00:35 +03:00
setShowSnippetAutocomplete ( false );
2026-01-08 19:58:31 +02:00
return ;
2025-12-07 19:32:53 +02:00
}
}
setShowCommandAutocomplete ( false );
const textBeforeCursor = value . substring ( 0 , cursorPosition );
2026-01-08 19:58:31 +02:00
const lastSlashSymbol = textBeforeCursor . lastIndexOf ( '/' );
if ( lastSlashSymbol !== - 1 ) {
const charBefore = lastSlashSymbol > 0 ? textBeforeCursor [ lastSlashSymbol - 1 ] : null ;
const textAfterSlash = textBeforeCursor . substring ( lastSlashSymbol + 1 );
const hasSeparator = textAfterSlash . includes ( ' ' ) || textAfterSlash . includes ( '\n' );
const isWordBoundary = ! charBefore || /\s/ . test ( charBefore );
if ( isWordBoundary && ! hasSeparator ) {
setSkillQuery ( textAfterSlash );
setShowSkillAutocomplete ( true );
setShowFileMention ( false );
return ;
}
}
setShowSkillAutocomplete ( false );
setSkillQuery ( '' );
2026-05-21 20:00:35 +03:00
const lastHashSymbol = textBeforeCursor . lastIndexOf ( '#' );
if ( lastHashSymbol !== - 1 ) {
const charBefore = lastHashSymbol > 0 ? textBeforeCursor [ lastHashSymbol - 1 ] : null ;
const textAfterHash = textBeforeCursor . substring ( lastHashSymbol + 1 );
const isWordBoundary = ! charBefore || /\s/ . test ( charBefore );
if ( isWordBoundary && ! textAfterHash . includes ( ' ' ) && ! textAfterHash . includes ( '\n' )) {
setSnippetQuery ( textAfterHash );
setShowSnippetAutocomplete ( true );
setShowFileMention ( false );
return ;
}
}
setShowSnippetAutocomplete ( false );
2025-12-07 19:32:53 +02:00
const lastAtSymbol = textBeforeCursor . lastIndexOf ( '@' );
if ( lastAtSymbol !== - 1 ) {
2026-02-18 20:08:42 +02:00
const charBefore = lastAtSymbol > 0 ? textBeforeCursor [ lastAtSymbol - 1 ] : null ;
2025-12-07 19:32:53 +02:00
const textAfterAt = textBeforeCursor . substring ( lastAtSymbol + 1 );
2026-02-18 20:08:42 +02:00
const isWordBoundary = ! charBefore || /\s/ . test ( charBefore );
if ( isWordBoundary && ! textAfterAt . includes ( ' ' ) && ! textAfterAt . includes ( '\n' )) {
2025-12-07 19:32:53 +02:00
setMentionQuery ( textAfterAt );
setShowFileMention ( true );
} else {
setShowFileMention ( false );
}
} else {
setShowFileMention ( false );
}
2026-05-25 21:27:10 +03:00
}, [ inputMode , setCommandQuery , setMentionQuery , setShowCommandAutocomplete , setShowFileMention , setShowSkillAutocomplete , setSkillQuery ]);
2025-12-07 19:32:53 +02:00
const insertTextAtSelection = React . useCallback (( text : string ) => {
if ( ! text ) {
return ;
}
const textarea = textareaRef . current ;
if ( ! textarea ) {
const nextValue = message + text ;
setMessage ( nextValue );
updateAutocompleteState ( nextValue , nextValue . length );
requestAnimationFrame (() => adjustTextareaHeight ());
return ;
}
const start = textarea . selectionStart ?? message . length ;
const end = textarea . selectionEnd ?? message . length ;
const nextValue = ` ${ message . substring ( 0 , start ) }${ text }${ message . substring ( end ) } ` ;
setMessage ( nextValue );
const cursorPosition = start + text . length ;
requestAnimationFrame (() => {
const currentTextarea = textareaRef . current ;
if ( currentTextarea ) {
currentTextarea . selectionStart = cursorPosition ;
currentTextarea . selectionEnd = cursorPosition ;
}
adjustTextareaHeight ();
});
updateAutocompleteState ( nextValue , cursorPosition );
}, [ adjustTextareaHeight , message , updateAutocompleteState ]);
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 ]);
const handleBeforeInput = React . useCallback (( e : React.FormEvent < HTMLTextAreaElement >) => {
if ( ! isVSCodeRuntime () || ! suppressNextFileDropTextInsertRef . current ) {
return ;
}
const nativeInputEvent = e . nativeEvent as InputEvent | undefined ;
if ( nativeInputEvent ? . inputType === 'insertFromDrop' ) {
e . preventDefault ();
clearDropTextSuppression ();
}
}, [ clearDropTextSuppression ]);
2025-12-07 19:32:53 +02:00
const handleTextChange = ( e : React.ChangeEvent < HTMLTextAreaElement >) => {
2026-03-23 23:51:55 +02:00
const nativeInputEvent = e . nativeEvent as InputEvent | undefined ;
if ( isVSCodeRuntime () && suppressNextFileDropTextInsertRef . current ) {
const candidateAbsolutePaths = pendingDroppedAbsolutePathsRef . current ;
const isLikelyDropTextInsertion = nativeInputEvent ? . inputType === 'insertFromDrop'
|| candidateAbsolutePaths . some (( path ) => path . length > 0 && e . target . value . includes ( path ));
if ( isLikelyDropTextInsertion ) {
clearDropTextSuppression ();
return ;
}
}
2025-12-07 19:32:53 +02:00
const value = e . target . value ;
const cursorPosition = e . target . selectionStart ?? value . length ;
2026-02-18 20:08:42 +02:00
if ( inputMode === 'normal' && value . startsWith ( '!' )) {
const shellCommand = value . slice ( 1 );
const nextCursor = Math . max ( 0 , cursorPosition - 1 );
setInputMode ( 'shell' );
setMessage ( shellCommand );
adjustTextareaHeight ();
setShowCommandAutocomplete ( false );
setShowSkillAutocomplete ( false );
setShowFileMention ( false );
requestAnimationFrame (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = nextCursor ;
textareaRef . current . selectionEnd = nextCursor ;
}
});
return ;
}
2025-12-07 19:32:53 +02:00
setMessage ( value );
adjustTextareaHeight ();
updateAutocompleteState ( value , cursorPosition );
};
2026-03-23 23:51:55 +02:00
React . useEffect (() => {
return () => {
clearDropTextSuppression ();
};
}, [ clearDropTextSuppression ]);
2025-12-07 19:32:53 +02:00
const handlePaste = React . useCallback ( async ( e : React.ClipboardEvent < HTMLTextAreaElement >) => {
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 )) {
const ta = textareaRef . current ;
const selStart = ta ? . selectionStart ?? - 1 ;
const selEnd = ta ? . selectionEnd ?? - 1 ;
if ( ta && selEnd > selStart ) {
const clipboardText = e . clipboardData . getData ( 'text' );
const url = clipboardText . trim ();
const selected = message . slice ( selStart , selEnd );
if (
PASTE_LINK_URL_PATTERN . test ( url )
&& ! /\s/ . test ( url )
&& selected . trim (). length > 0
&& ! selected . includes ( '](' )
) {
e . preventDefault ();
const next = ` ${ message . slice ( 0 , selStart ) } [ ${ selected } ]( ${ url } ) ${ message . slice ( selEnd ) } ` ;
const caret = selStart + 1 + selected . length + 2 + url . length + 1 ;
setMessage ( next );
requestAnimationFrame (() => {
const current = textareaRef . current ;
if ( current ) {
current . selectionStart = caret ;
current . selectionEnd = caret ;
}
adjustTextareaHeight ();
});
updateAutocompleteState ( next , caret );
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 ());
if ( imageFiles . length === 0 ) {
return ;
}
2025-12-21 20:18:51 +02:00
if ( ! currentSessionId && ! newSessionDraftOpen ) {
2025-12-07 19:32:53 +02:00
return ;
}
e . preventDefault ();
const pastedText = e . clipboardData . getData ( 'text' );
2026-05-24 23:47:22 +03:00
const assignedFilenames = assignImageAttachmentFilenames (
imageFiles ,
[
... attachedFiles . map (( file ) => file . filename ),
... pendingPastedAttachmentFilenamesRef . current ,
],
);
const citationText = buildAttachmentCitationText ( assignedFilenames );
const textarea = textareaRef . current ;
const selectionStart = textarea ? . selectionStart ?? message . length ;
const selectionEnd = textarea ? . selectionEnd ?? message . length ;
const insertionText = withInlineInsertionBoundaries (
buildImagePasteInsertion ( pastedText , citationText ),
message . slice ( 0 , selectionStart ),
message . slice ( selectionEnd ),
);
insertTextAtSelection ( insertionText );
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-05-24 23:47:22 +03:00
}, [ addAttachedFile , attachedFiles , adjustTextareaHeight , currentSessionId , inputMode , 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
const cursorPosition = textareaRef . current ? . selectionStart || 0 ;
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 ()
: ( toProjectRelativeMentionPath ( file . path ) || file . name );
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 (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = nextCursor ;
textareaRef . current . selectionEnd = nextCursor ;
}
adjustTextareaHeight ();
updateAutocompleteState ( newMessage , nextCursor );
});
2026-02-04 01:14:10 -08:00
} else if ( textareaRef . current ) {
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 (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = nextCursor ;
textareaRef . current . selectionEnd = nextCursor ;
}
adjustTextareaHeight ();
updateAutocompleteState ( newMessage , nextCursor );
});
2025-12-07 19:32:53 +02:00
}
setShowFileMention ( false );
setMentionQuery ( '' );
textareaRef . current ? . focus ();
};
const handleAgentSelect = ( agentName : string ) => {
const textarea = textareaRef . current ;
const cursorPosition = textarea ? . selectionStart ?? message . length ;
const textBeforeCursor = message . substring ( 0 , cursorPosition );
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 (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = nextCursor ;
textareaRef . current . selectionEnd = nextCursor ;
}
adjustTextareaHeight ();
updateAutocompleteState ( newMessage , nextCursor );
});
2026-02-04 01:14:10 -08:00
} else if ( textareaRef . current ) {
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 (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = nextCursor ;
textareaRef . current . selectionEnd = nextCursor ;
}
adjustTextareaHeight ();
updateAutocompleteState ( newMessage , nextCursor );
});
2025-12-07 19:32:53 +02:00
}
2026-02-18 20:08:42 +02:00
setShowFileMention ( false );
setMentionQuery ( '' );
2025-12-07 19:32:53 +02:00
textareaRef . current ? . focus ();
};
2026-01-08 19:58:31 +02:00
const handleSkillSelect = ( skillName : string ) => {
const textarea = textareaRef . current ;
const cursorPosition = textarea ? . selectionStart ?? message . length ;
const textBeforeCursor = message . substring ( 0 , cursorPosition );
const lastSlashSymbol = textBeforeCursor . lastIndexOf ( '/' );
if ( lastSlashSymbol !== - 1 ) {
const newMessage =
message . substring ( 0 , lastSlashSymbol ) +
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 (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = nextCursor ;
textareaRef . current . selectionEnd = nextCursor ;
}
adjustTextareaHeight ();
updateAutocompleteState ( newMessage , nextCursor );
});
}
setShowSkillAutocomplete ( false );
setSkillQuery ( '' );
textareaRef . current ? . focus ();
};
2026-05-21 20:00:35 +03:00
const handleSnippetSelect = ( _snippet : unknown , trigger : string ) => {
const textarea = textareaRef . current ;
const cursorPosition = textarea ? . selectionStart ?? message . length ;
const textBeforeCursor = message . substring ( 0 , cursorPosition );
const lastHashSymbol = textBeforeCursor . lastIndexOf ( '#' );
const startIndex = lastHashSymbol !== - 1 ? lastHashSymbol : cursorPosition ;
const newMessage = ` ${ message . substring ( 0 , startIndex ) } # ${ trigger } ${ message . substring ( cursorPosition ) } ` ;
setMessage ( newMessage );
const nextCursor = startIndex + trigger . length + 2 ;
requestAnimationFrame (() => {
if ( textareaRef . current ) {
textareaRef . current . selectionStart = nextCursor ;
textareaRef . current . selectionEnd = nextCursor ;
}
adjustTextareaHeight ();
updateAutocompleteState ( newMessage , nextCursor );
});
setShowSnippetAutocomplete ( false );
setSnippetQuery ( '' );
textareaRef . current ? . focus ();
};
2026-04-22 22:08:34 +03:00
const handleCommandSelect = ( command : CommandInfo ) => {
2025-12-07 19:32:53 +02:00
setMessage ( `/ ${ command . name } ` );
const textareaElement = textareaRef . current as HTMLTextAreaElement & { _commandMetadata? : typeof command };
if ( textareaElement ) {
textareaElement . _commandMetadata = command ;
}
setShowCommandAutocomplete ( false );
setCommandQuery ( '' );
2026-02-04 01:14:10 -08:00
const refocus = () => {
2025-12-07 19:32:53 +02:00
if ( textareaRef . current ) {
2026-02-04 01:14:10 -08:00
try {
textareaRef . current . focus ({ preventScroll : true });
} catch {
textareaRef . current . focus ();
}
2025-12-07 19:32:53 +02:00
textareaRef . current . setSelectionRange ( textareaRef . current . value . length , textareaRef . current . value . length );
}
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 (() => {
if ( currentSessionId && textareaRef . current && ! isMobile ) {
textareaRef . current . focus ();
}
}, [ 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 ]);
const hasDraggedFiles = React . useCallback (( dataTransfer : DataTransfer | null | undefined ) : boolean => {
if ( ! dataTransfer ) return false ;
if ( dataTransfer . files && dataTransfer . files . length > 0 ) return true ;
2026-02-28 02:21:12 +08:00
if ( dataTransfer . types ) {
const types = Array . from ( dataTransfer . types );
2026-03-23 23:51:55 +02:00
const lowerTypes = types . map (( type ) => type . toLowerCase ());
if ( lowerTypes . includes ( 'files' )) return true ;
if ( lowerTypes . includes ( 'text/uri-list' )) return true ;
if ( lowerTypes . includes ( 'codefiles' )) return true ;
2026-04-22 01:31:14 +08:00
if ( lowerTypes . includes ( 'application/x-openchamber-file-path' )) return true ;
2026-03-23 23:51:55 +02:00
if ( lowerTypes . some (( type ) => type . includes ( 'vnd.code.tree' ))) return true ;
}
for ( const dataType of VS_CODE_DROP_DATA_TYPES ) {
let payload = '' ;
try {
payload = dataTransfer . getData ( dataType );
} catch {
continue ;
}
if ( payload && parseDroppedFileReferences ( payload ). length > 0 ) {
return true ;
}
2026-02-28 02:21:12 +08:00
}
2026-03-23 23:51:55 +02:00
return false ;
2026-02-11 19:28:22 +02:00
}, []);
const collectDroppedFiles = React . useCallback (( dataTransfer : DataTransfer | null | undefined ) : File [] => {
if ( ! dataTransfer ) return [];
const directFiles = Array . from ( dataTransfer . files || []);
if ( directFiles . length > 0 ) {
return directFiles ;
}
const fromItems = Array . from ( dataTransfer . items || [])
. filter (( item ) => item . kind === 'file' )
. map (( item ) => item . getAsFile ())
. filter (( file ) : file is File => Boolean ( file ));
return fromItems ;
}, []);
2026-02-28 02:21:12 +08:00
const collectDroppedFileUris = React . useCallback (( dataTransfer : DataTransfer | null | undefined ) : string [] => {
if ( ! dataTransfer || typeof dataTransfer . getData !== 'function' ) return [];
2026-03-23 23:51:55 +02:00
const extracted = new Set < string >();
2026-02-28 02:21:12 +08:00
2026-03-23 23:51:55 +02:00
for ( const dataType of VS_CODE_DROP_DATA_TYPES ) {
let rawPayload = '' ;
try {
rawPayload = dataTransfer . getData ( dataType );
} catch {
continue ;
2026-02-28 02:21:12 +08:00
}
2026-03-23 23:51:55 +02:00
if ( ! rawPayload ) {
continue ;
2026-02-28 02:21:12 +08:00
}
2026-03-23 23:51:55 +02:00
for ( const candidate of parseDroppedFileReferences ( rawPayload )) {
extracted . add ( candidate );
2026-02-28 02:21:12 +08:00
}
}
2026-03-23 23:51:55 +02:00
return Array . from ( extracted );
}, []);
2026-02-28 02:21:12 +08:00
2026-02-11 19:28:22 +02:00
const normalizeDroppedPath = React . useCallback (( rawPath : string ) : string => {
const input = rawPath . trim ();
if ( ! input . toLowerCase (). startsWith ( 'file://' )) {
return input ;
}
try {
let pathname = decodeURIComponent ( new URL ( input ). pathname || '' );
if ( /^\/[A-Za-z]:\// . test ( pathname )) {
pathname = pathname . slice ( 1 );
}
return pathname || input ;
} catch {
const stripped = input . replace ( /^file:\/\//i , '' );
try {
return decodeURIComponent ( stripped );
} catch {
return stripped ;
}
}
}, []);
2026-03-04 01:41:01 +02:00
const toProjectRelativeMentionPath = React . useCallback (( absolutePath : string ) : string => {
const normalizedAbsolutePath = absolutePath . replace ( /\\/g , '/' ). trim ();
const normalizedRoot = ( chatSearchDirectory || '' ). replace ( /\\/g , '/' ). replace ( /\/+$/ , '' );
if ( ! normalizedRoot ) {
return normalizedAbsolutePath ;
}
if ( normalizedAbsolutePath === normalizedRoot ) {
return normalizedAbsolutePath ;
}
const rootWithSlash = ` ${ normalizedRoot } /` ;
if ( normalizedAbsolutePath . startsWith ( rootWithSlash )) {
return normalizedAbsolutePath . slice ( rootWithSlash . length );
}
return normalizedAbsolutePath ;
}, [ chatSearchDirectory ]);
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 ))
. map (( entry ) => toProjectRelativeMentionPath ( entry ))
. 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-04-26 16:58:07 +03:00
}, [ normalizeDroppedPath , setPendingInputText , t , toProjectRelativeMentionPath ]);
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 } ` ;
const textarea = textareaRef . current ;
const currentMessage = messageRef . current ;
if ( textarea ) {
const pos = textarea . selectionStart ?? cursorPosRef . current ;
const end = textarea . selectionEnd ?? pos ;
const before = currentMessage . slice ( 0 , pos );
const after = currentMessage . slice ( end );
const needSpaceBefore = before . length > 0 && ! /\s$/ . test ( before );
const needSpaceAfter = after . length > 0 && ! /^\s/ . test ( after );
const insert = ` ${ needSpaceBefore ? ' ' : '' }${ mention }${ needSpaceAfter ? ' ' : '' } ` ;
const nextMessage = ` ${ before }${ insert }${ after } ` ;
setMessage ( nextMessage );
requestAnimationFrame (() => {
const cursorPos = pos + insert . length ;
textarea . selectionStart = cursorPos ;
textarea . selectionEnd = cursorPos ;
cursorPosRef . current = cursorPos ;
textarea . focus ();
});
} else {
setMessage (( prev ) => appendInlineText ( prev , mention ));
}
clearDropTextSuppression ();
return ;
}
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 ) {
for ( const file of files ) {
try {
await addAttachedFile ( file );
} catch ( error ) {
console . error ( 'File attach failed' , error );
2026-04-26 14:03:39 +03:00
toast . error ( error instanceof Error ? error.message : 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
};
2026-02-11 19:28:22 +02:00
// Tauri desktop: handle native file drops via onDragDropEvent
React . useEffect (() => {
if ( ! isTauriShell ()) return ;
let cancelled = false ;
let unlisten : (() => void ) | null = null ;
void ( async () => {
try {
const { getCurrentWebviewWindow } = await import ( '@tauri-apps/api/webviewWindow' );
const webviewWindow = getCurrentWebviewWindow ();
const removeListener = await webviewWindow . onDragDropEvent ( async ( event ) => {
if ( ! canAcceptDropRef . current ) return ;
const payload = ( event as { payload? : unknown }). payload ;
if ( ! payload || typeof payload !== 'object' ) return ;
const typed = payload as { type ?: string ; paths? : string []; position ?: { x? : number ; y? : number } };
const type = typed . type ;
const x = typed . position ? . x ;
const y = typed . position ? . y ;
// Check if drop is inside the chat input area
const zone = dropZoneRef . current ;
2026-03-04 01:41:01 +02:00
let inZone : boolean | null = null ;
2026-02-11 19:28:22 +02:00
if ( zone && typeof x === 'number' && typeof y === 'number' ) {
const rect = zone . getBoundingClientRect ();
inZone = x >= rect . left && x <= rect . right && y >= rect . top && y <= rect . bottom ;
// Handle retina displays where Tauri might report physical pixels
if ( ! inZone && window . devicePixelRatio > 1 ) {
const sx = x / window . devicePixelRatio ;
const sy = y / window . devicePixelRatio ;
inZone = sx >= rect . left && sx <= rect . right && sy >= rect . top && sy <= rect . bottom ;
}
}
if ( type === 'enter' || type === 'over' ) {
2026-03-04 01:41:01 +02:00
if ( inZone !== null ) {
nativeDragInsideDropZoneRef . current = inZone ;
}
setIsDragging ( nativeDragInsideDropZoneRef . current );
2026-02-11 19:28:22 +02:00
return ;
}
if ( type === 'leave' ) {
2026-03-04 01:41:01 +02:00
nativeDragInsideDropZoneRef . current = false ;
2026-02-11 19:28:22 +02:00
setIsDragging ( false );
return ;
}
if ( type === 'drop' ) {
2026-03-04 01:41:01 +02:00
const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef . current ;
nativeDragInsideDropZoneRef . current = false ;
2026-02-11 19:28:22 +02:00
setIsDragging ( false );
2026-03-04 01:41:01 +02:00
if ( ! shouldHandleDrop ) return ;
2026-02-11 19:28:22 +02:00
const paths = Array . isArray ( typed . paths )
? typed . paths . filter (( p ) : p is string => typeof p === 'string' )
: [];
if ( paths . length === 0 ) return ;
for ( const path of paths ) {
try {
const normalizedPath = normalizeDroppedPath ( path );
const fileName = normalizedPath . split ( /[\\/]/ ). pop () || normalizedPath ;
let file : File ;
2026-03-04 01:41:01 +02:00
// In Tauri shell, dropped paths are local machine paths.
// Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions.
if ( isTauriShell ()) {
2026-02-11 19:28:22 +02:00
const { invoke } = await import ( '@tauri-apps/api/core' );
const result = await invoke < { mime : string ; base64 : string } > ( 'desktop_read_file' , { path : normalizedPath });
const byteCharacters = atob ( result . base64 );
const byteNumbers = new Array ( byteCharacters . length );
for ( let i = 0 ; i < byteCharacters . length ; i ++ ) {
byteNumbers [ i ] = byteCharacters . charCodeAt ( i );
}
const byteArray = new Uint8Array ( byteNumbers );
const blob = new Blob ([ byteArray ], { type : result . mime || 'application/octet-stream' });
file = new File ([ blob ], fileName , { type : result . mime || 'application/octet-stream' });
} else {
const response = await fetch ( `/api/fs/raw?path= ${ encodeURIComponent ( normalizedPath ) } ` );
if ( ! response . ok ) {
throw new Error ( `Failed to read dropped file ( ${ response . status } )` );
}
const blob = await response . blob ();
file = new File ([ blob ], fileName , { type : blob . type || 'application/octet-stream' });
}
await addAttachedFile ( file );
} catch ( error ) {
console . error ( 'Failed to attach dropped file:' , path , error );
2026-04-26 14:03:39 +03:00
toast . error ( t ( 'chat.chatInput.toast.attachNamedFailed' , {
name : path.split ( /[\\/]/ ). pop () || t ( 'chat.chatInput.fileFallback' ),
}));
2026-02-11 19:28:22 +02:00
}
}
}
});
if ( cancelled ) {
removeListener ();
return ;
}
unlisten = removeListener ;
} catch ( error ) {
if ( ! cancelled ) {
console . warn ( 'Failed to register Tauri drag-drop listener:' , error );
}
}
})();
return () => {
cancelled = true ;
if ( unlisten ) unlisten ();
};
2026-04-26 16:58:07 +03:00
}, [ addAttachedFile , normalizeDroppedPath , t ]);
2026-02-11 19:28:22 +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 );
for ( const file of list ) {
try {
await addAttachedFile ( file );
} catch ( error ) {
console . error ( 'File attach failed' , error );
2026-04-26 14:03:39 +03:00
toast . error ( error instanceof Error ? error.message : t ( 'chat.chatInput.toast.attachFileFailed' ));
2025-12-15 13:15:45 +02:00
}
}
2026-04-26 16:58:07 +03:00
}, [ addAttachedFile , t ]);
2025-12-15 13:15:45 +02:00
const handleVSCodePickFiles = React . useCallback ( async () => {
try {
const response = await fetch ( '/api/vscode/pick-files' );
const data = await response . json ();
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-04-26 16:58:07 +03:00
}, [ attachFiles , t ]);
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 ;
const selectedDraftProject = React . useMemo (() => {
const explicit = newSessionDraft ? . selectedProjectId
? projects . find (( project ) => project . id === newSessionDraft . selectedProjectId ) ?? null
: null ;
if ( explicit ) {
return explicit ;
}
const active = activeProjectId
? projects . find (( project ) => project . id === activeProjectId ) ?? null
: null ;
if ( active ) {
return active ;
}
return projects [ 0 ] ?? null ;
}, [ activeProjectId , newSessionDraft ? . selectedProjectId , projects ]);
const selectedDraftProjectPath = React . useMemo (
() => normalizePath ( selectedDraftProject ? . path ?? null ),
[ selectedDraftProject ? . path ],
);
const selectedDraftProjectBranches = useGitBranches ( selectedDraftProjectPath );
2026-05-15 21:30:21 +03:00
const selectedDraftProjectIsGitRepo = useIsGitRepo ( selectedDraftProjectPath );
const fetchGitStatus = useGitStore (( state ) => state . fetchStatus );
2026-03-20 01:01:03 +02:00
const fetchBranches = useGitStore (( state ) => state . fetchBranches );
const [ isDiscoveringDraftBranches , setIsDiscoveringDraftBranches ] = React . useState ( false );
React . useEffect (() => {
2026-05-15 21:30:21 +03:00
if ( ! showDraftTargetSelectors || ! selectedDraftProjectPath || ! runtimeGit || selectedDraftProjectIsGitRepo !== null ) {
return ;
}
void fetchGitStatus ( selectedDraftProjectPath , runtimeGit , { silent : true });
}, [ fetchGitStatus , runtimeGit , selectedDraftProjectIsGitRepo , selectedDraftProjectPath , showDraftTargetSelectors ]);
React . useEffect (() => {
if ( ! showDraftTargetSelectors || ! selectedDraftProjectPath || ! selectedDraftProject || ! runtimeGit || selectedDraftProjectIsGitRepo !== true ) {
2026-03-20 01:01:03 +02:00
setIsDiscoveringDraftBranches ( false );
return ;
}
if ( selectedDraftProjectBranches ? . all ) {
setIsDiscoveringDraftBranches ( false );
return ;
}
let cancelled = false ;
setIsDiscoveringDraftBranches ( true );
void fetchBranches ( selectedDraftProjectPath , runtimeGit )
. finally (() => {
if ( ! cancelled ) {
setIsDiscoveringDraftBranches ( false );
}
});
return () => {
cancelled = true ;
};
2026-05-15 21:30:21 +03:00
}, [ fetchBranches , runtimeGit , selectedDraftProject , selectedDraftProjectBranches ? . all , selectedDraftProjectIsGitRepo , selectedDraftProjectPath , showDraftTargetSelectors ]);
2026-03-20 01:01:03 +02:00
2026-04-17 16:07:26 +08:00
const selectedDraftProjectCurrentBranch = selectedDraftProjectBranches ? . current ? . trim () ?? '' ;
2026-03-20 01:01:03 +02:00
const projectRootBranchOption = React . useMemo (() => {
if ( ! selectedDraftProject ) {
return null ;
}
const value = normalizePath ( selectedDraftProject . path );
if ( ! value ) {
return null ;
}
2026-04-17 16:07:26 +08:00
if ( ! selectedDraftProjectCurrentBranch ) {
2026-03-20 01:01:03 +02:00
return null ;
}
return {
value ,
2026-04-17 16:07:26 +08:00
label : selectedDraftProjectCurrentBranch ,
2026-03-20 01:01:03 +02:00
};
2026-04-17 16:07:26 +08:00
}, [ selectedDraftProject , selectedDraftProjectCurrentBranch ]);
2026-03-20 01:01:03 +02:00
const worktreeBranchOptions = React . useMemo (() => {
if ( ! selectedDraftProject ) {
2026-04-17 01:13:59 +08:00
return [];
2026-03-20 01:01:03 +02:00
}
const worktrees = (() => {
if ( ! selectedDraftProjectPath ) {
return [];
}
return availableWorktreesByProject . get ( selectedDraftProjectPath )
?? availableWorktreesByProject . get ( selectedDraftProject . path )
?? [];
})();
2026-04-17 01:13:59 +08:00
return buildSessionTargetOptions ({
projectRoot : normalizePath ( selectedDraftProject . path ) ?? '' ,
2026-04-17 16:07:26 +08:00
rootBranch : selectedDraftProjectCurrentBranch ,
2026-04-17 01:13:59 +08:00
worktrees ,
pendingBootstrapDirectory : newSessionDraft?.bootstrapPendingDirectory ?? null ,
});
2026-04-17 16:07:26 +08:00
}, [ availableWorktreesByProject , newSessionDraft ? . bootstrapPendingDirectory , selectedDraftProject , selectedDraftProjectCurrentBranch , selectedDraftProjectPath ]);
2026-03-20 01:01:03 +02:00
const selectedDraftDirectory = React . useMemo (
2026-03-22 22:31:29 +02:00
() => normalizePath ( newSessionDraft ? . bootstrapPendingDirectory ?? null )
?? normalizePath ( newSessionDraft ? . directoryOverride ?? null )
?? selectedDraftProjectPath ,
[ newSessionDraft ? . bootstrapPendingDirectory , newSessionDraft ? . directoryOverride , selectedDraftProjectPath ],
2026-03-20 01:01:03 +02:00
);
2026-03-22 22:31:29 +02:00
const shouldKeepMissingSelectedDraftDirectory = React . useMemo (() => {
const pendingDirectory = normalizePath ( newSessionDraft ? . bootstrapPendingDirectory ?? null );
return Boolean (
newSessionDraft ? . preserveDirectoryOverride
||
newSessionDraft ? . pendingWorktreeRequestId
|| ( pendingDirectory && pendingDirectory === selectedDraftDirectory )
);
}, [ newSessionDraft ? . bootstrapPendingDirectory , newSessionDraft ? . pendingWorktreeRequestId , newSessionDraft ? . preserveDirectoryOverride , selectedDraftDirectory ]);
2026-03-20 01:01:03 +02:00
const draftBranchItems = React . useMemo (() => {
const baseItems : Array < { value : string ; label : string } > = [];
if ( projectRootBranchOption ) {
baseItems . push ( projectRootBranchOption );
}
baseItems . push (... worktreeBranchOptions );
if ( ! selectedDraftDirectory ) {
return baseItems ;
}
if ( baseItems . some (( option ) => option . value === selectedDraftDirectory )) {
return baseItems ;
}
2026-03-22 22:31:29 +02:00
if ( ! shouldKeepMissingSelectedDraftDirectory ) {
return baseItems ;
}
2026-03-20 01:01:03 +02:00
return [
... baseItems ,
{ value : selectedDraftDirectory , label : formatDirectoryName ( selectedDraftDirectory ) },
];
2026-03-22 22:31:29 +02:00
}, [ projectRootBranchOption , selectedDraftDirectory , shouldKeepMissingSelectedDraftDirectory , worktreeBranchOptions ]);
2026-03-20 01:01:03 +02:00
const selectedDraftBranchLabel = React . useMemo (() => {
const selectedValue = selectedDraftDirectory ?? draftBranchItems [ 0 ] ? . value ?? null ;
if ( ! selectedValue ) {
return null ;
}
return draftBranchItems . find (( item ) => item . value === selectedValue ) ? . label ?? formatDirectoryName ( selectedValue );
}, [ draftBranchItems , selectedDraftDirectory ]);
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
const selectedDraftBranchIsKnown = React . useMemo (() => {
if ( ! selectedDraftDirectory ) {
return true ;
}
if ( projectRootBranchOption ? . value === selectedDraftDirectory ) {
return true ;
}
return worktreeBranchOptions . some (( option ) => option . value === selectedDraftDirectory );
}, [ projectRootBranchOption ? . value , selectedDraftDirectory , worktreeBranchOptions ]);
2026-03-22 22:31:29 +02:00
React . useEffect (() => {
if ( ! newSessionDraft ? . open || ! newSessionDraft ? . preserveDirectoryOverride ) {
return ;
}
if ( ! selectedDraftDirectory || ! selectedDraftBranchIsKnown ) {
return ;
}
2026-03-31 18:47:00 +03:00
useSessionUIStore . getState (). setDraftPreserveDirectoryOverride ( false );
2026-03-22 22:31:29 +02:00
}, [ newSessionDraft ? . open , newSessionDraft ? . preserveDirectoryOverride , selectedDraftBranchIsKnown , selectedDraftDirectory ]);
2026-03-20 01:01:03 +02:00
const shouldShowDraftBranchSelector = React . useMemo (() => {
2026-05-15 21:30:21 +03:00
if ( selectedDraftProjectIsGitRepo !== true ) {
return false ;
}
2026-03-20 01:01:03 +02:00
if ( isDiscoveringDraftBranches ) {
return false ;
}
if ( projectRootBranchOption ) {
return true ;
}
return worktreeBranchOptions . length > 0 ;
2026-05-15 21:30:21 +03:00
}, [ isDiscoveringDraftBranches , projectRootBranchOption , selectedDraftProjectIsGitRepo , worktreeBranchOptions . length ]);
2026-03-20 01:01:03 +02:00
const handleDraftProjectChange = React . useCallback (( projectId : string ) => {
2026-03-31 18:47:00 +03:00
const draft = useSessionUIStore . getState (). newSessionDraft ;
2026-03-22 22:31:29 +02:00
if ( draft ? . pendingWorktreeRequestId || draft ? . bootstrapPendingDirectory || draft ? . preserveDirectoryOverride ) {
return ;
}
2026-03-20 01:01:03 +02:00
const project = projects . find (( entry ) => entry . id === projectId );
if ( ! project ) {
return ;
}
if ( activeProjectId !== projectId ) {
setActiveProjectIdOnly ( projectId );
}
setNewSessionDraftTarget ({
projectId ,
directoryOverride : project.path ,
2026-03-22 22:31:29 +02:00
}, { force : true });
2026-03-20 01:01:03 +02:00
}, [ activeProjectId , projects , setActiveProjectIdOnly , setNewSessionDraftTarget ]);
const handleDraftDirectoryChange = React . useCallback (( directory : string ) => {
2026-03-31 18:47:00 +03:00
const draft = useSessionUIStore . getState (). newSessionDraft ;
2026-03-22 22:31:29 +02:00
if ( draft ? . pendingWorktreeRequestId || draft ? . bootstrapPendingDirectory || draft ? . preserveDirectoryOverride ) {
return ;
}
2026-03-20 01:01:03 +02:00
if ( ! selectedDraftProject ) {
return ;
}
setNewSessionDraftTarget ({
projectId : selectedDraftProject.id ,
directoryOverride : directory ,
2026-03-22 22:31:29 +02:00
}, { force : true });
2026-03-20 01:01:03 +02:00
}, [ selectedDraftProject , setNewSessionDraftTarget ]);
const renderProjectLabelWithIcon = React . useCallback (( project : {
id : string ;
path : string ;
label? : string ;
icon? : string | null ;
color? : string | null ;
iconImage ?: { mime : string ; updatedAt : number ; source : 'custom' | 'auto' } | null ;
iconBackground? : string | null ;
}) => {
const imageUrl = getProjectIconImageUrl (
{ id : project.id , iconImage : project.iconImage ?? null },
{
themeVariant : currentTheme.metadata.variant ,
iconColor : currentTheme.colors.surface.foreground ,
},
);
2026-05-13 13:26:15 +03:00
const projectIconName = project . icon ? PROJECT_ICON_MAP [ project . icon ] : null ;
2026-03-20 01:01:03 +02:00
const iconColor = getProjectIconColor ( project . color );
return (
< span className = "inline-flex min-w-0 items-center gap-1.5" >
{ imageUrl ? (
< span
className = "inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center overflow-hidden rounded-[3px]"
style = { project . iconBackground ? { backgroundColor : project.iconBackground } : undefined }
>
< img src = { imageUrl } alt = "" className = "h-full w-full object-contain" draggable = { false } />
</ span >
2026-05-13 13:26:15 +03:00
) : projectIconName ? (
< Icon name = { projectIconName } className = "h-3.5 w-3.5 shrink-0" style = { iconColor ? { color : iconColor } : undefined } />
2026-03-20 01:01:03 +02:00
) : (
2026-05-13 13:26:15 +03:00
< Icon name = "folder" className = "h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style = { iconColor ? { color : iconColor } : undefined }/>
2026-03-20 01:01:03 +02:00
)}
< span className = "truncate" >{ getProjectDisplayLabel ( project )}</ span >
</ span >
);
}, [ currentTheme . colors . surface . foreground , currentTheme . metadata . variant ]);
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
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 ) {
return false ;
}
return state . isSessionAutoAccepting ( permissionScopeSessionId );
});
const handlePermissionAutoAcceptToggle = React . useCallback (() => {
if ( ! permissionScopeSessionId ) {
2026-04-26 14:03:39 +03:00
toast . error ( t ( 'chat.chatInput.toast.openSessionFirst' ));
2026-03-20 01:01:03 +02:00
return ;
}
const nextEnabled = ! permissionAutoAcceptEnabled ;
setSessionAutoAccept ( permissionScopeSessionId , nextEnabled ). catch (() => {
2026-04-26 14:03:39 +03:00
toast . error ( t ( 'chat.chatInput.toast.togglePermissionAutoAcceptFailed' ));
2026-03-20 01:01:03 +02:00
});
2026-04-26 16:58:07 +03:00
}, [ permissionAutoAcceptEnabled , permissionScopeSessionId , 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-02-01 22:43:46 +02:00
onSubmit = {( e ) => { e . preventDefault (); handlePrimaryAction (); }}
2026-01-14 19:25:18 +08:00
className = { cn (
2026-02-01 18:29:34 +02:00
"relative pt-0 pb-4" ,
2026-02-23 05:04:25 +07:00
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4' ,
2026-04-26 17:44:33 +03:00
isMobile && 'bottom-safe-area'
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-04-26 18:04:42 +03:00
< div className = { cn ( 'chat-input-column relative overflow-visible' , isDesktopExpanded && '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-02-05 03:14:26 +02:00
{ hasDrafts && (
2026-04-29 17:03:38 -04:00
< div className = "flex flex-wrap items-center gap-2 pb-2" >
{ reviewCount > 0 ? (
< div
className = "inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style = {{
backgroundColor : currentTheme?.colors?.surface?.elevated ,
borderColor : currentTheme?.colors?.interactive?.border ,
}}
>
< span className = "text-xs font-medium text-muted-foreground" >{ t ( 'chat.chatInput.reviewComments' )}</ span >
< span className = "text-xs font-semibold" style = {{ color : currentTheme?.colors?.status?.info }}>{ reviewCount }</ span >
</ div >
) : null }
{ previewConsoleCount > 0 ? (
< div
className = "inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style = {{
backgroundColor : currentTheme?.colors?.surface?.elevated ,
borderColor : currentTheme?.colors?.interactive?.border ,
}}
>
< span className = "text-xs font-medium text-muted-foreground" >{ t ( 'chat.chatInput.devServerLogs' )}</ span >
< span className = "text-xs font-semibold" style = {{ color : currentTheme?.colors?.status?.info }}>{ previewConsoleCount }</ span >
< button
type = "button"
className = "ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
onClick = {() => removePreviewDrafts ( 'preview-console' )}
aria-label = { t ( 'chat.chatInput.devServerLogsRemove' )}
title = { t ( 'chat.chatInput.devServerLogsRemove' )}
>
2026-05-13 13:26:15 +03:00
< Icon name = "close" className = "h-3 w-3" />
2026-04-29 17:03:38 -04:00
</ button >
</ div >
) : null }
{ previewAnnotationCount > 0 ? (
< div
className = "inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
style = {{
backgroundColor : currentTheme?.colors?.surface?.elevated ,
borderColor : currentTheme?.colors?.interactive?.border ,
}}
>
< span className = "text-xs font-medium text-muted-foreground" >{ t ( 'chat.chatInput.previewAnnotations' )}</ span >
< span className = "text-xs font-semibold" style = {{ color : currentTheme?.colors?.status?.info }}>{ previewAnnotationCount }</ span >
< button
type = "button"
className = "ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
onClick = {() => removePreviewDrafts ( 'preview-annotation' )}
aria-label = { t ( 'chat.chatInput.previewContextRemove' )}
title = { t ( 'chat.chatInput.previewContextRemove' )}
>
2026-05-13 13:26:15 +03:00
< Icon name = "close" className = "h-3 w-3" />
2026-04-29 17:03:38 -04:00
</ button >
</ div >
) : null }
2026-02-05 03:14:26 +02:00
</ div >
)}
2026-03-03 00:20:15 +02:00
2026-03-04 01:41:01 +02:00
{ /* Linked Issue row */ }
{ linkedIssue && ! isVSCode && (
2026-03-03 00:20:15 +02:00
< div className = "pb-2 w-full px-1" >
2026-05-08 09:05:22 -04:00
< div className = "flex w-full items-center gap-1.5 text-sm h-5 px-1" >
< button
type = "button"
onClick = {() => setIssuePickerOpen ( true )}
className = "flex min-w-0 flex-1 items-center gap-1.5 text-left hover:opacity-80 transition-opacity"
>
{ linkedIssue . author ? . avatarUrl && (
< img
src = { linkedIssue . author . avatarUrl }
alt = { linkedIssue . author . login }
className = "h-5 w-5 rounded-full flex-shrink-0"
/>
2026-03-03 00:20:15 +02:00
)}
2026-05-08 09:05:22 -04:00
< span className = "text-muted-foreground flex-shrink-0" >
# { linkedIssue . number }
{ linkedIssue . author && (
< span className = "ml-1" >{ t ( 'chat.chatInput.linked.byAuthor' , { author : linkedIssue.author.login })}</ span >
)}
</ span >
< span className = "text-foreground truncate" >
{ linkedIssue . title }
</ span >
</ button >
2026-03-04 01:41:01 +02:00
< span className = "flex items-center gap-0.5 flex-shrink-0" >
< a
href = { linkedIssue . url }
target = "_blank"
rel = "noopener noreferrer"
className = "flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.linked.issue.openInBrowserAria' )}
2026-03-04 01:41:01 +02:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "external-link" className = "h-4 w-4 text-muted-foreground" />
2026-03-04 01:41:01 +02:00
</ a >
2026-05-08 09:05:22 -04:00
< button
type = "button"
onClick = {() => {
2026-03-04 01:41:01 +02:00
setLinkedIssue ( null );
}}
2026-05-08 09:05:22 -04:00
className = "flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.linked.issue.removeAria' )}
2026-05-08 09:05:22 -04:00
title = { t ( 'chat.chatInput.linked.issue.removeAria' )}
2026-03-04 01:41:01 +02:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "close" className = "h-4 w-4 text-muted-foreground" />
2026-05-08 09:05:22 -04:00
</ button >
2026-03-04 01:41:01 +02:00
</ span >
2026-05-08 09:05:22 -04:00
</ div >
2026-03-04 01:41:01 +02:00
</ div >
)}
{ linkedPr && ! isVSCode && (
< div className = "pb-2 w-full px-1" >
2026-05-08 09:05:22 -04:00
< div className = "flex w-full items-center gap-1.5 text-sm h-5 px-1" >
< button
type = "button"
onClick = {() => setPrPickerOpen ( true )}
className = "flex min-w-0 flex-1 items-center gap-1.5 text-left hover:opacity-80 transition-opacity"
>
{ linkedPr . author ? . avatarUrl && (
< img
src = { linkedPr . author . avatarUrl }
alt = { linkedPr . author . login }
className = "h-5 w-5 rounded-full flex-shrink-0"
/>
2026-03-04 01:41:01 +02:00
)}
2026-05-08 09:05:22 -04:00
< span className = "text-muted-foreground flex-shrink-0" >
{ t ( 'chat.chatInput.linked.pr.number' , { number : linkedPr . number })}
{ linkedPr . author && (
< span className = "ml-1" >{ t ( 'chat.chatInput.linked.byAuthor' , { author : linkedPr.author.login })}</ span >
)}
</ span >
< span className = "text-foreground truncate" >
{ linkedPr . title }
</ span >
< span className = "text-muted-foreground flex-shrink-0 typography-meta" >
{ linkedPr . head } → { linkedPr . base }
</ span >
</ button >
2026-03-04 01:41:01 +02:00
< span className = "flex items-center gap-0.5 flex-shrink-0" >
< a
href = { linkedPr . url }
target = "_blank"
rel = "noopener noreferrer"
className = "flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.linked.pr.openInBrowserAria' )}
2026-03-04 01:41:01 +02:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "external-link" className = "h-4 w-4 text-muted-foreground" />
2026-03-04 01:41:01 +02:00
</ a >
2026-05-08 09:05:22 -04:00
< button
type = "button"
onClick = {() => {
2026-03-04 01:41:01 +02:00
setLinkedPr ( null );
}}
2026-05-08 09:05:22 -04:00
className = "flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
2026-04-26 14:03:39 +03:00
aria-label = { t ( 'chat.chatInput.linked.pr.removeAria' )}
2026-05-08 09:05:22 -04:00
title = { t ( 'chat.chatInput.linked.pr.removeAria' )}
2026-03-04 01:41:01 +02:00
>
2026-05-13 13:26:15 +03:00
< Icon name = "close" className = "h-4 w-4 text-muted-foreground" />
2026-05-08 09:05:22 -04:00
</ button >
2026-03-04 01:41:01 +02:00
</ span >
2026-05-08 09:05:22 -04:00
</ div >
2026-03-03 00:20:15 +02:00
</ div >
)}
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-03-20 01:01:03 +02:00
{ showDraftTargetSelectors && selectedDraftProject ? (
< div className = "mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5" >
< Select
value = { selectedDraftProject . id }
onValueChange = { handleDraftProjectChange }
>
< SelectTrigger
size = "sm"
2026-04-19 15:52:54 +03:00
className = "h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
2026-03-20 01:01:03 +02:00
>
< SelectValue >
{ renderProjectLabelWithIcon ( selectedDraftProject )}
</ SelectValue >
</ SelectTrigger >
< SelectContent fitContent >
{ projects . map (( project ) => (
< SelectItem key = { project . id } value = { project . id } className = "max-w-[24rem] truncate" >
{ renderProjectLabelWithIcon ( project )}
</ SelectItem >
))}
</ SelectContent >
</ Select >
{ shouldShowDraftBranchSelector ? (
< Select
value = { selectedDraftDirectory ?? draftBranchItems [ 0 ] ? . value ?? normalizePath ( selectedDraftProject . path ) ?? '' }
onValueChange = { handleDraftDirectoryChange }
>
< SelectTrigger
size = "sm"
2026-04-19 15:52:54 +03:00
className = "h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
2026-03-20 01:01:03 +02:00
>
< SelectValue >
2026-04-26 14:03:39 +03:00
{ selectedDraftBranchLabel ?? t ( 'chat.chatInput.branch' )}
2026-03-20 01:01:03 +02:00
</ SelectValue >
</ SelectTrigger >
< SelectContent fitContent >
{ projectRootBranchOption ? (
< SelectGroup >
2026-04-26 14:03:39 +03:00
< SelectLabel >{ t ( 'chat.chatInput.projectRoot' )}</ SelectLabel >
2026-03-20 01:01:03 +02:00
< SelectItem key = { projectRootBranchOption . value } value = { projectRootBranchOption . value } className = "max-w-[24rem] truncate" >
{ projectRootBranchOption . label }
</ SelectItem >
</ SelectGroup >
) : null }
2026-03-22 22:31:29 +02:00
{ projectRootBranchOption ? < SelectSeparator /> : null }
< SelectGroup >
< div className = "flex items-center justify-between px-2 py-1.5" >
2026-04-26 14:03:39 +03:00
< span className = "text-muted-foreground typography-meta" >{ t ( 'chat.chatInput.worktrees' )}</ span >
2026-03-22 22:31:29 +02:00
< button
type = "button"
className = "text-muted-foreground typography-meta hover:text-foreground cursor-pointer"
onPointerDown = {( e ) => { e . stopPropagation (); }}
onClick = {( e ) => { e . preventDefault (); e . stopPropagation (); void createWorktreeDraft (); }}
>
2026-04-26 14:03:39 +03:00
{ t ( 'chat.chatInput.worktreeNew' )}
2026-03-22 22:31:29 +02:00
</ button >
</ div >
{ worktreeBranchOptions . map (( option ) => (
< SelectItem key = { option . value } value = { option . value } className = "max-w-[24rem] truncate" >
2026-04-17 01:13:59 +08:00
{ option . pending ? '⏳ ' : '' }{ option . label }
2026-03-22 22:31:29 +02:00
</ SelectItem >
))}
</ SelectGroup >
2026-03-20 01:01:03 +02:00
{ selectedDraftDirectory && ! selectedDraftBranchIsKnown ? (
< SelectItem value = { selectedDraftDirectory } className = "max-w-[24rem] truncate" >
{ selectedDraftBranchLabel }
</ SelectItem >
) : null }
</ SelectContent >
</ Select >
) : null }
</ div >
) : null }
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-02-23 05:04:25 +07:00
isDesktopExpanded && 'flex-1 min-h-0' ,
2026-02-01 18:29:34 +02:00
"border border-border/80" ,
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
2025-12-07 19:32:53 +02:00
{ showCommandAutocomplete && (
< CommandAutocomplete
ref = { commandRef }
searchQuery = { commandQuery }
onCommandSelect = { handleCommandSelect }
onClose = {() => setShowCommandAutocomplete ( false )}
2026-02-23 05:04:25 +07:00
style = { isDesktopExpanded && autocompleteOverlayPosition
? {
left : ` ${ autocompleteOverlayPosition . left } px` ,
top : ` ${ autocompleteOverlayPosition . top } px` ,
bottom : 'auto' ,
width : `min(450px, calc(100% - ${ autocompleteOverlayPosition . left + 8 } px))` ,
maxHeight : ` ${ autocompleteOverlayPosition . maxHeight } px` ,
transform : autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined ,
}
: undefined }
2025-12-07 19:32:53 +02:00
/>
)}
2026-02-04 01:14:10 -08:00
{ }
{ showSkillAutocomplete && (
< SkillAutocomplete
ref = { skillRef }
searchQuery = { skillQuery }
onSkillSelect = { handleSkillSelect }
onClose = {() => setShowSkillAutocomplete ( false )}
2026-02-23 05:04:25 +07:00
style = { isDesktopExpanded && autocompleteOverlayPosition
? {
left : ` ${ autocompleteOverlayPosition . left } px` ,
top : ` ${ autocompleteOverlayPosition . top } px` ,
bottom : 'auto' ,
width : `min(360px, calc(100% - ${ autocompleteOverlayPosition . left + 8 } px))` ,
maxHeight : ` ${ autocompleteOverlayPosition . maxHeight } px` ,
transform : autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined ,
}
: undefined }
2026-02-04 01:14:10 -08:00
/>
)}
2026-01-08 19:58:31 +02:00
2026-05-21 20:00:35 +03:00
{ showSnippetAutocomplete && (
< SnippetAutocomplete
ref = { snippetRef }
searchQuery = { snippetQuery }
onSnippetSelect = { handleSnippetSelect }
onClose = {() => setShowSnippetAutocomplete ( false )}
style = { isDesktopExpanded && autocompleteOverlayPosition
? {
left : ` ${ autocompleteOverlayPosition . left } px` ,
top : ` ${ autocompleteOverlayPosition . top } px` ,
bottom : 'auto' ,
width : `min(450px, calc(100% - ${ autocompleteOverlayPosition . left + 8 } px))` ,
maxHeight : ` ${ autocompleteOverlayPosition . maxHeight } px` ,
transform : autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined ,
}
: undefined }
/>
)}
2026-02-04 01:14:10 -08:00
{ showFileMention && (
2026-01-08 19:58:31 +02:00
2025-12-07 19:32:53 +02:00
< FileMentionAutocomplete
ref = { mentionRef }
searchQuery = { mentionQuery }
onFileSelect = { handleFileSelect }
2026-02-18 20:08:42 +02:00
onAgentSelect = { handleAgentSelect }
2025-12-07 19:32:53 +02:00
onClose = {() => setShowFileMention ( false )}
2026-02-23 05:04:25 +07:00
style = { isDesktopExpanded && autocompleteOverlayPosition
? {
left : ` ${ autocompleteOverlayPosition . left } px` ,
top : ` ${ autocompleteOverlayPosition . top } px` ,
bottom : 'auto' ,
width : `min(520px, calc(100% - ${ autocompleteOverlayPosition . left + 8 } px))` ,
maxHeight : ` ${ autocompleteOverlayPosition . maxHeight } px` ,
transform : autocompleteOverlayPosition.place === 'above' ? 'translateY(-100%)' : undefined ,
}
: undefined }
2025-12-07 19:32:53 +02:00
/>
)}
2026-05-05 00:24:03 +02:00
< div className = { cn ( "overflow-hidden" , isDesktopExpanded && 'flex flex-1 min-h-0 flex-col' )}>
< 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 >
< div className = { cn ( "relative overflow-hidden" , isDesktopExpanded && 'flex flex-1 min-h-0 flex-col' )}>
{ highlightedComposerContent && (
< div
aria - hidden
className = { cn (
'pointer-events-none absolute inset-0 z-0 whitespace-pre-wrap break-words px-3 rounded-b-none' ,
isDesktopExpanded
? 'h-full min-h-0 py-4'
: isMobile
? 'py-2.5'
: 'pt-4 pb-2' ,
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label' ,
)}
ref = { composerHighlightRef }
>
{ highlightedComposerContent . map (( part , index ) => (
< span
key = { ` ${ index } - ${ part . text . length } ` }
2026-05-24 17:21:11 +03:00
className = { part . className }
2026-05-05 00:24:03 +02:00
>
{ part . text }
</ span >
))}
</ div >
)}
< Textarea
simple
ref = { textareaRef }
data-chat-input = "true"
value = { message }
onChange = { handleTextChange }
onBeforeInput = { handleBeforeInput }
onKeyDown = { handleKeyDown }
onPaste = { handlePaste }
onDragEnter = { handleDragEnter }
onDragOver = { handleDragOver }
onDropCapture = { handleDropCapture }
onDrop = { handleDrop }
onDragEnd = { handleDragEnd }
onKeyUp = { updateAutocompleteOverlayPosition }
onClick = { updateAutocompleteOverlayPosition }
onScroll = {( event ) => {
updateAutocompleteOverlayPosition ();
const scrollTop = event . currentTarget . scrollTop ;
if ( composerHighlightRef . current ) {
composerHighlightRef . current . style . transform = `translateY(- ${ scrollTop } px)` ;
}
}}
onSelect = {( e ) => {
const ta = e . currentTarget ;
cursorPosRef . current = ta . selectionStart ?? 0 ;
updateAutocompleteOverlayPosition ();
}}
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' )}
disabled = { ! currentSessionId && ! newSessionDraftOpen }
autoCorrect = { isMobile ? "on" : "off" }
autoCapitalize = { isMobile ? "sentences" : "off" }
spellCheck = { isMobile || inputSpellcheckEnabled }
fillContainer = { isDesktopExpanded }
outerClassName = { cn ( 'ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0' , isDesktopExpanded && 'flex-1 min-h-0' )}
2026-03-04 01:41:01 +02:00
className = { cn (
2026-05-05 00:24:03 +02:00
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10' ,
2026-03-04 01:41:01 +02:00
isDesktopExpanded
? 'h-full min-h-0 py-4'
: isMobile
? 'py-2.5'
: 'pt-4 pb-2' ,
2026-05-05 00:24:03 +02:00
inputMode === 'shell' && 'font-mono' ,
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]' ,
2026-03-04 01:41:01 +02:00
)}
2026-05-05 00:24:03 +02:00
style = {{
flex : isDesktopExpanded ? '1 1 auto' : 'none' ,
height : ! isDesktopExpanded && textareaSize ? ` ${ textareaSize . height } px` : undefined ,
maxHeight : ! isDesktopExpanded && textareaSize ? ` ${ textareaSize . maxHeight } px` : undefined ,
borderTopLeftRadius : chatInputRadius ,
borderTopRightRadius : chatInputRadius ,
}}
rows = { 1 }
/>
</ div >
2026-03-04 01:41:01 +02:00
</ div >
2025-12-07 19:32:53 +02:00
< div
className = { cn (
2026-03-04 11:28:16 +02:00
'bg-transparent flex-shrink-0' ,
2025-12-07 19:32:53 +02:00
footerPaddingClass ,
2026-01-29 22:27:26 +02:00
isMobile ? 'flex items-center gap-x-1.5' : cn ( 'flex items-center justify-between' , footerGapClass )
2025-12-07 19:32:53 +02:00
)}
2026-01-18 18:28:49 +06:00
style = {{
2026-03-20 01:01:03 +02:00
borderBottomLeftRadius : chatInputRadius ,
borderBottomRightRadius : chatInputRadius ,
2026-01-18 18:28:49 +06:00
}}
2025-12-07 19:32:53 +02:00
data-chat-input-footer = "true"
>
2026-01-29 22:27:26 +02:00
{ isMobile ? (
2026-01-30 06:13:37 -03:00
<>
2026-02-05 00:07:24 +08:00
< div className = "flex w-full items-center justify-between gap-x-1.5" >
2026-03-20 01:01:03 +02:00
< div className = "flex items-center gap-x-1.5" >
2026-04-04 02:19:55 +03:00
< ComposerAttachmentControls
isVSCode = { isVSCode }
footerIconButtonClass = { footerIconButtonClass }
iconSizeClass = { iconSizeClass }
fileInputRef = { fileInputRef }
handleLocalFileSelect = { handleLocalFileSelect }
handlePickLocalFiles = { handlePickLocalFiles }
openIssuePicker = { openIssuePicker }
openPrPicker = { openPrPicker }
onOpenSettings = { onOpenSettings }
/>
< PermissionAutoAcceptButton
footerIconButtonClass = { footerIconButtonClass }
iconSizeClass = { iconSizeClass }
permissionScopeSessionId = { permissionScopeSessionId }
permissionAutoAcceptEnabled = { permissionAutoAcceptEnabled }
handlePermissionAutoAcceptToggle = { handlePermissionAutoAcceptToggle }
/>
2026-01-30 06:13:37 -03:00
</ div >
2026-02-07 06:30:08 -03:00
< div className = "flex items-center min-w-0 gap-x-1 justify-end" >
< div className = "flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink" >
2026-04-27 04:28:51 -06:00
< MemoMobileModelButton onOpenModel = {() => handleOpenMobilePanel ( 'model' )} className = "min-w-0 flex-shrink" />
2026-04-04 02:19:55 +03:00
< MemoMobileAgentButton
onOpenAgentPanel = { handleOpenAgentPanel }
2026-02-24 17:44:43 +08:00
onCycleAgent = { handleCycleAgent }
className = "min-w-0 flex-shrink"
/>
2026-02-07 06:30:08 -03:00
</ div >
< div className = "flex items-center gap-x-1 flex-shrink-0" >
2026-04-04 02:19:55 +03:00
< MemoBrowserVoiceButton />
< ComposerActionButtons
isMobile = { isMobile }
footerIconButtonClass = { footerIconButtonClass }
sendIconSizeClass = { sendIconSizeClass }
stopIconSizeClass = { stopIconSizeClass }
canSend = { canSend }
canAbort = { canAbort }
hasContent = { !! hasContent }
currentSessionId = { currentSessionId }
newSessionDraftOpen = { newSessionDraftOpen }
onPrimaryAction = { handlePrimaryAction }
onQueueMessage = { handleQueueMessage }
onAbort = { handleAbort }
/>
2026-02-07 06:30:08 -03:00
</ div >
2026-01-29 22:27:26 +02:00
</ div >
</ div >
2026-04-04 02:19:55 +03:00
< MemoModelControls
2026-01-30 06:13:37 -03:00
className = "hidden"
mobilePanel = { mobileControlsPanel }
onMobilePanelChange = { setMobileControlsPanel }
/>
</>
2026-01-29 22:27:26 +02:00
) : (
<>
< div className = { cn ( "flex items-center flex-shrink-0" , footerGapClass )}>
2026-04-04 02:19:55 +03:00
< ComposerAttachmentControls
isVSCode = { isVSCode }
footerIconButtonClass = { footerIconButtonClass }
iconSizeClass = { iconSizeClass }
fileInputRef = { fileInputRef }
handleLocalFileSelect = { handleLocalFileSelect }
handlePickLocalFiles = { handlePickLocalFiles }
openIssuePicker = { openIssuePicker }
openPrPicker = { openPrPicker }
onOpenSettings = { onOpenSettings }
/>
< FocusModeButton
footerIconButtonClass = { footerIconButtonClass }
iconSizeClass = { iconSizeClass }
isExpandedInput = { isExpandedInput }
onToggle = { handleToggleExpandedInput }
/>
< PermissionAutoAcceptButton
footerIconButtonClass = { footerIconButtonClass }
iconSizeClass = { iconSizeClass }
permissionScopeSessionId = { permissionScopeSessionId }
permissionAutoAcceptEnabled = { permissionAutoAcceptEnabled }
handlePermissionAutoAcceptToggle = { handlePermissionAutoAcceptToggle }
withTooltip
/>
2026-01-29 22:27:26 +02:00
</ div >
< div className = { cn ( 'flex items-center flex-1 justify-end' , footerGapClass , 'md:gap-x-3' )}>
2026-04-04 02:19:55 +03:00
< MemoModelControls className = { cn ( 'flex-1 min-w-0 justify-end' )} />
< MemoBrowserVoiceButton />
< ComposerActionButtons
isMobile = { isMobile }
footerIconButtonClass = { footerIconButtonClass }
sendIconSizeClass = { sendIconSizeClass }
stopIconSizeClass = { stopIconSizeClass }
canSend = { canSend }
canAbort = { canAbort }
hasContent = { !! hasContent }
currentSessionId = { currentSessionId }
newSessionDraftOpen = { newSessionDraftOpen }
onPrimaryAction = { handlePrimaryAction }
onQueueMessage = { handleQueueMessage }
onAbort = { handleAbort }
/>
2026-01-29 22:27:26 +02:00
</ div >
</>
)}
2025-12-07 19:32:53 +02:00
</ div >
2026-02-24 23:38:12 +08:00
{ /* Mobile Session Status Bar - above input */ }
2026-04-19 15:52:54 +03:00
{ isMobile && < MobileSessionStatusBar />}
2026-02-07 09:11:55 +08:00
</ div >
2025-12-07 19:32:53 +02:00
</ div >
</ 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-05-24 23:18:32 +03:00
< ToolOutputDialog
popup = { attachmentPreview }
onOpenChange = { handleAttachmentPreviewOpenChange }
syntaxTheme = {{}}
isMobile = { isMobile }
/>
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 );