feat(chat): align command, shell, and subtask UX (#444)

* feat: reload interface after skills operations

- Adds configurable delay before interface reload after skills changes
- Introduces polling to wait for application health after reload
- Updates UI to show reload message when installing or modifying skills

* feat: distinguish skills from commands in UI

- Displays skill badge for commands that are registered skills
- Prevents editing skills through command management interface
- Triggers interface reload after skill operations to reflect changes

* feat(chat): align command and subtask UX with opencode parity

Route commands/shell via parity paths, render delegated subtasks cleanly, and surface child-session permission/question prompts in parent chat.

* fix(ProjectEditDialog): improve layout consistency

* fix: update task icon and session handling in ToolPart

* feat(chat): add shell-mode input and collapse shell bridge output

Switch leading ! to shell mode UX and fold synthetic shell bridge assistant messages into the user shell bubble with inline output actions.

* fix: remove AI agent icon from file mention autocomplete

* fix: remove unused icon import from file mention component
This commit is contained in:
Bohdan Triapitsyn
2026-02-18 20:08:42 +02:00
committed by GitHub
parent e4a2486312
commit 85f21cb945
23 changed files with 1557 additions and 318 deletions
+79 -99
View File
@@ -20,7 +20,6 @@ import { AttachedFilesList } from './FileAttachment';
import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
import { cn } from '@/lib/utils';
import { ServerFilePicker } from './ServerFilePicker';
@@ -77,13 +76,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
return draft;
});
const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
const [isDragging, setIsDragging] = React.useState(false);
const [showFileMention, setShowFileMention] = React.useState(false);
const [mentionQuery, setMentionQuery] = React.useState('');
const [showCommandAutocomplete, setShowCommandAutocomplete] = React.useState(false);
const [commandQuery, setCommandQuery] = React.useState('');
const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false);
const [agentQuery, setAgentQuery] = React.useState('');
const [autocompleteTab, setAutocompleteTab] = React.useState<'commands' | 'agents' | 'files'>('commands');
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
const [skillQuery, setSkillQuery] = React.useState('');
@@ -98,7 +96,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const canAcceptDropRef = React.useRef(false);
const mentionRef = React.useRef<FileMentionHandle>(null);
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
const sendMessage = useSessionStore((state) => state.sendMessage);
@@ -210,6 +207,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
React.useEffect(() => {
if (prevSessionIdRef.current !== currentSessionId) {
prevSessionIdRef.current = currentSessionId;
setInputMode('normal');
if (!persistChatDraft) {
// Clear draft when switching sessions if persist is disabled
@@ -542,9 +540,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
textareaRef.current?.blur();
}
// Handle slash commands locally before sending
// Handle local slash commands only in normal mode
const normalizedCommand = primaryText.trimStart();
if (normalizedCommand.startsWith('/')) {
if (inputMode === 'normal' && normalizedCommand.startsWith('/')) {
const commandName = normalizedCommand
.slice(1)
.trim()
@@ -571,28 +569,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setMessage('');
return; // Don't send to assistant
}
// /compact - call SDK summarize endpoint
else if (commandName === 'compact' && currentSessionId) {
try {
const { opencodeClient } = await import('@/lib/opencode/client');
const directory = opencodeClient.getDirectory();
const response = await opencodeClient.getApiClient().session.summarize({
sessionID: currentSessionId,
directory: directory || undefined,
providerID: currentProviderId,
modelID: currentModelId,
});
if (response.error) {
throw new Error('Failed to compact session');
}
scrollToBottom?.({ instant: true, force: true });
} catch (error) {
console.error('Failed to compact session:', error);
toast.error('Failed to compact session');
}
setMessage('');
return; // Don't send to assistant
}
}
// Collect all attachments for error recovery
@@ -609,7 +585,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
primaryAttachments,
agentMentionName,
additionalParts.length > 0 ? additionalParts : undefined,
currentVariant
currentVariant,
inputMode
).catch((error: unknown) => {
const rawMessage =
error instanceof Error
@@ -663,13 +640,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Primary action for send button - respects queue mode setting
const handlePrimaryAction = React.useCallback(() => {
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
if (queueModeEnabled && canQueue) {
handleQueueMessage();
} else {
void handleSubmitRef.current();
}
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
}, [inputMode, hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
// Auto-send queued messages when session becomes idle (but not after abort)
React.useEffect(() => {
@@ -706,6 +683,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Uses keyCode === 229 fallback for WebKit where compositionend fires before keydown.
if (isIMECompositionEvent(e)) return;
if (inputMode === 'shell' && e.key === 'Escape') {
e.preventDefault();
setInputMode('normal');
return;
}
if (inputMode === 'shell' && e.key === 'Backspace' && message.length === 0) {
e.preventDefault();
setInputMode('normal');
return;
}
if (showCommandAutocomplete && commandRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
@@ -714,14 +703,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}
if (showAgentAutocomplete && agentRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
agentRef.current.handleKeyDown(e.key);
return;
}
}
if (showSkillAutocomplete && skillRef.current) {
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
e.preventDefault();
@@ -738,7 +719,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}
if (e.key === 'Tab' && !showCommandAutocomplete && !showAgentAutocomplete && !showFileMention) {
if (e.key === 'Tab' && !showCommandAutocomplete && !showFileMention) {
e.preventDefault();
handleCycleAgent();
return;
@@ -747,7 +728,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Handle ArrowUp/ArrowDown for message history navigation
// ArrowUp: only when cursor at start (position 0) or input is empty
// ArrowDown: also works when cursor at end (to cycle forward through history)
const isAnyAutocompleteOpen = showCommandAutocomplete || showAgentAutocomplete || showSkillAutocomplete || showFileMention;
const isAnyAutocompleteOpen = showCommandAutocomplete || showSkillAutocomplete || showFileMention;
const cursorAtStart = textareaRef.current?.selectionStart === 0 && textareaRef.current?.selectionEnd === 0;
const cursorAtEnd = textareaRef.current?.selectionStart === message.length && textareaRef.current?.selectionEnd === message.length;
const canNavigateHistoryUp = !isAnyAutocompleteOpen && (message.length === 0 || cursorAtStart);
@@ -800,7 +781,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// 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
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && sessionPhase !== 'idle';
if (queueModeEnabled) {
if (isCtrlEnter || !canQueue) {
@@ -896,6 +877,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, [adjustTextareaHeight, message, isMobile]);
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
if (inputMode === 'shell') {
setShowCommandAutocomplete(false);
setShowFileMention(false);
setShowSkillAutocomplete(false);
return;
}
if (value.startsWith('/')) {
const firstSpace = value.indexOf(' ');
const firstNewline = value.indexOf('\n');
@@ -910,7 +898,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setAutocompleteTab('commands');
setShowCommandAutocomplete(true);
setShowFileMention(false);
setShowAgentAutocomplete(false);
setShowSkillAutocomplete(false);
return;
}
@@ -920,25 +907,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const textBeforeCursor = value.substring(0, cursorPosition);
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
if (lastHashSymbol !== -1) {
const charBefore = lastHashSymbol > 0 ? textBeforeCursor[lastHashSymbol - 1] : null;
const textAfterHash = textBeforeCursor.substring(lastHashSymbol + 1);
const hasSeparator = textAfterHash.includes(' ') || textAfterHash.includes('\n');
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !hasSeparator) {
setAgentQuery(textAfterHash);
setAutocompleteTab('agents');
setShowAgentAutocomplete(true);
setShowFileMention(false);
return;
}
}
setShowAgentAutocomplete(false);
setAgentQuery('');
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
if (lastSlashSymbol !== -1) {
const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null;
@@ -950,7 +918,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setSkillQuery(textAfterSlash);
setShowSkillAutocomplete(true);
setShowFileMention(false);
setShowAgentAutocomplete(false);
return;
}
}
@@ -960,10 +927,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastAtSymbol !== -1) {
const charBefore = lastAtSymbol > 0 ? textBeforeCursor[lastAtSymbol - 1] : null;
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
setMentionQuery(textAfterAt);
setAutocompleteTab('files');
setAutocompleteTab('agents');
setShowFileMention(true);
} else {
setShowFileMention(false);
@@ -971,12 +940,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
} else {
setShowFileMention(false);
}
}, [setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
}, [inputMode, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
const applyAutocompletePrefix = React.useCallback((prefix: '/' | '#' | '@') => {
const applyAutocompletePrefix = React.useCallback((prefix: '/' | '@') => {
const nextMessage = message.length === 0
? prefix
: (message[0] === '/' || message[0] === '#' || message[0] === '@')
: (message[0] === '/' || message[0] === '@')
? `${prefix}${message.slice(1)}`
: `${prefix}${message}`;
setMessage(nextMessage);
@@ -1008,22 +977,20 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
setAutocompleteTab(tab);
setCommandQuery('');
setAgentQuery('');
setMentionQuery('');
if (tab === 'commands') {
applyAutocompletePrefix('/');
}
if (tab === 'agents') {
applyAutocompletePrefix('#');
applyAutocompletePrefix('@');
}
if (tab === 'files') {
applyAutocompletePrefix('@');
}
setShowSkillAutocomplete(false);
setShowCommandAutocomplete(tab === 'commands');
setShowAgentAutocomplete(tab === 'agents');
setShowFileMention(tab === 'files');
}, [applyAutocompletePrefix, isMobile, setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
setShowFileMention(tab === 'agents' || tab === 'files');
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
const handleOpenCommandMenu = React.useCallback(() => {
if (!isMobile) {
@@ -1047,10 +1014,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setCommandQuery('');
setAutocompleteTab('commands');
setShowCommandAutocomplete(true);
setShowAgentAutocomplete(false);
setShowFileMention(false);
setShowSkillAutocomplete(false);
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
const insertTextAtSelection = React.useCallback((text: string) => {
if (!text) {
@@ -1087,6 +1053,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const cursorPosition = e.target.selectionStart ?? value.length;
if (inputMode === 'normal' && value.startsWith('!')) {
const shellCommand = value.slice(1);
const nextCursor = Math.max(0, cursorPosition - 1);
setInputMode('shell');
setMessage(shellCommand);
adjustTextareaHeight();
setShowCommandAutocomplete(false);
setShowSkillAutocomplete(false);
setShowFileMention(false);
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
textareaRef.current.selectionEnd = nextCursor;
}
});
return;
}
setMessage(value);
adjustTextareaHeight();
updateAutocompleteState(value, cursorPosition);
@@ -1186,16 +1171,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const textarea = textareaRef.current;
const cursorPosition = textarea?.selectionStart ?? message.length;
const textBeforeCursor = message.substring(0, cursorPosition);
const lastHashSymbol = textBeforeCursor.lastIndexOf('#');
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
if (lastHashSymbol !== -1) {
if (lastAtSymbol !== -1) {
const newMessage =
message.substring(0, lastHashSymbol) +
`#${agentName} ` +
message.substring(0, lastAtSymbol) +
`@${agentName} ` +
message.substring(cursorPosition);
setMessage(newMessage);
const nextCursor = lastHashSymbol + agentName.length + 2;
const nextCursor = lastAtSymbol + agentName.length + 2;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
@@ -1207,7 +1192,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
} else if (textareaRef.current) {
const newMessage =
message.substring(0, cursorPosition) +
`#${agentName} ` +
`@${agentName} ` +
message.substring(cursorPosition);
setMessage(newMessage);
@@ -1222,8 +1207,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
});
}
setShowAgentAutocomplete(false);
setAgentQuery('');
setShowFileMention(false);
setMentionQuery('');
textareaRef.current?.focus();
};
@@ -1237,11 +1222,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (lastSlashSymbol !== -1) {
const newMessage =
message.substring(0, lastSlashSymbol) +
`${skillName} ` +
`/${skillName} ` +
message.substring(cursorPosition);
setMessage(newMessage);
const nextCursor = lastSlashSymbol + skillName.length + 1;
const nextCursor = lastSlashSymbol + skillName.length + 2;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
@@ -1914,7 +1899,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
className={cn(
"flex flex-col relative overflow-visible",
"border border-border/80",
"focus-within:ring-1 focus-within:ring-primary/50",
"focus-within:ring-1",
inputMode === 'shell'
? 'focus-within:ring-[var(--status-info)]'
: 'focus-within:ring-primary/50',
isDragging && "ring-2 ring-primary ring-offset-2"
)}
style={{
@@ -1958,18 +1946,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
/>
)}
{ }
{showAgentAutocomplete && (
<AgentMentionAutocomplete
ref={agentRef}
searchQuery={agentQuery}
onAgentSelect={handleAgentSelect}
showTabs={isMobile}
activeTab={autocompleteTab}
onTabSelect={handleAutocompleteTabSelect}
onClose={() => setShowAgentAutocomplete(false)}
/>
)}
{showSkillAutocomplete && (
<SkillAutocomplete
ref={skillRef}
@@ -1985,6 +1961,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
ref={mentionRef}
searchQuery={mentionQuery}
onFileSelect={handleFileSelect}
onAgentSelect={handleAgentSelect}
showTabs={isMobile}
activeTab={autocompleteTab}
onTabSelect={handleAutocompleteTabSelect}
@@ -2003,7 +1980,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
onDrop={handleDrop}
onPointerDownCapture={handleTextareaPointerDownCapture}
placeholder={currentSessionId || newSessionDraftOpen
? "# for agents; @ for files; / for commands"
? inputMode === 'shell'
? "Enter shell command..."
: "@ for files/agents; / for commands; ! for shell"
: "Select or create a session to start chatting"}
disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"}
@@ -2012,6 +1991,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
outerClassName="focus-within:ring-0"
className={cn(
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
inputMode === 'shell' && 'font-mono',
isMobile ? "py-2.5" : "pt-4 pb-2"
)}
style={{