Feat/mobile quick actions (#282)

* feat(mobile): add quick actions button and improve mobile UX

Add mobile-optimized quick action buttons and improve autocomplete UX.

- Add quick command button (/) in mobile chat footer
- Add tabbed autocomplete interface for Commands/Agents/Files
- Fix agent selector conflict with model selector
- Improve TerminalViewport Android IME support
- Add reusable Slider component for settings
- Update StatusChip styling for mobile layout

Files changed:
- ChatInput.tsx: Mobile quick actions, command menu, autocomplete tabs
- AgentMentionAutocomplete.tsx: Tab support for mobile
- CommandAutocomplete.tsx: Tab support and touch handling
- FileMentionAutocomplete.tsx: Tab support for mobile
- ModelControls.tsx: Agent selector fix
- StatusChip.tsx: Mobile layout improvements
- TerminalViewport.tsx: Android IME improvements
- ui/slider.tsx: New reusable slider component

* feat(chat): remove unused BrowserVoiceButton component from ChatInput

* fix: Resolve linter errors
This commit is contained in:
gsxdsm
2026-02-04 11:14:10 +02:00
committed by GitHub
parent 66da4ad8f4
commit ea0deec14d
8 changed files with 656 additions and 196 deletions
@@ -16,10 +16,15 @@ export interface AgentMentionAutocompleteHandle {
handleKeyDown: (key: string) => void;
}
type AutocompleteTab = 'commands' | 'agents' | 'files';
interface AgentMentionAutocompleteProps {
searchQuery: string;
onAgentSelect: (agentName: string) => void;
onClose: () => void;
showTabs?: boolean;
activeTab?: AutocompleteTab;
onTabSelect?: (tab: AutocompleteTab) => void;
}
const isMentionable = (mode?: string | null): boolean => {
@@ -33,11 +38,15 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
searchQuery,
onAgentSelect,
onClose,
showTabs,
activeTab = 'agents',
onTabSelect,
}, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const ignoreTabClickRef = React.useRef(false);
const { getVisibleAgents } = useConfigStore();
const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
@@ -177,7 +186,47 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
}
event.preventDefault();
event.stopPropagation();
ignoreTabClickRef.current = true;
onTabSelect?.(tab.id);
}}
onClick={() => {
if (ignoreTabClickRef.current) {
ignoreTabClickRef.current = false;
return;
}
onTabSelect?.(tab.id);
}}
>
{tab.label}
</button>
))}
</div>
</div>
) : null}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
{agents.length ? (
<div>
{agents.map((agent, index) => renderAgent(agent, index))}
+232 -146
View File
@@ -4,6 +4,7 @@ import {
RiAddCircleLine,
RiAiAgentLine,
RiAttachment2,
RiCommandLine,
RiFileUploadLine,
RiSendPlane2Line,
} from '@remixicon/react';
@@ -12,8 +13,6 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { appendInlineComments } from '@/lib/messages/inlineComments';
import { AttachedFilesList } from './FileAttachment';
import { QueuedMessageChips } from './QueuedMessageChips';
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
@@ -62,6 +61,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
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('');
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
@@ -114,20 +114,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const addToQueue = useMessageQueueStore((state) => state.addToQueue);
const clearQueue = useMessageQueueStore((state) => state.clearQueue);
// 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]
)
);
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
const hasDrafts = draftCount > 0;
// Session activity for auto-send on idle
const { phase: sessionPhase } = useCurrentSessionActivity();
const prevSessionPhaseRef = React.useRef(sessionPhase);
@@ -239,7 +225,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}, [pendingInputText, consumePendingInputText]);
const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts;
const hasContent = message.trim() || attachedFiles.length > 0;
const hasQueuedMessages = queuedMessages.length > 0;
const canSend = hasContent || hasQueuedMessages;
@@ -249,16 +235,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const handleQueueMessage = React.useCallback(() => {
if (!hasContent || !currentSessionId) return;
// Get and consume drafts for this session
const sessionKey = currentSessionId;
const drafts = consumeDrafts(sessionKey);
// Build message with appended drafts
let messageToQueue = message.replace(/^\n+|\n+$/g, '');
if (drafts.length > 0) {
messageToQueue = appendInlineComments(messageToQueue, drafts);
}
const messageToQueue = message.replace(/^\n+|\n+$/g, '');
const attachmentsToQueue = attachedFiles.map((file) => ({ ...file }));
addToQueue(currentSessionId, {
@@ -275,7 +252,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (!isMobile) {
textareaRef.current?.focus();
}
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile]);
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -300,7 +277,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
for (let i = 0; i < queuedMessages.length; i++) {
const queuedMsg = queuedMessages[i];
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
// Use agent mention from first message that has one
if (!agentMentionName && mention?.name) {
agentMentionName = mention.name;
@@ -342,28 +319,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
}
// Get session key for drafts (use currentSessionId or 'draft' for new sessions)
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
let drafts: import('@/stores/useInlineCommentDraftStore').InlineCommentDraft[] = [];
if (sessionKey) {
drafts = consumeDrafts(sessionKey);
}
// Append drafts to the message if any exist
if (drafts.length > 0) {
if (queuedMessages.length === 0) {
// No queue - append to primary text
primaryText = appendInlineComments(primaryText, drafts);
} else if (additionalParts.length > 0) {
// Has queue with additional parts - append to the last part (current input)
const lastPart = additionalParts[additionalParts.length - 1];
lastPart.text = appendInlineComments(lastPart.text, drafts);
} else {
// Has queue but no additional parts yet (shouldn't happen with hasContent check, but handle it)
primaryText = appendInlineComments(primaryText, drafts);
}
}
if (!primaryText && additionalParts.length === 0) return;
// Clear queue and input
@@ -448,44 +403,44 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
additionalParts.length > 0 ? additionalParts : undefined,
currentVariant
).catch((error: unknown) => {
const rawMessage =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error ?? '');
const normalized = rawMessage.toLowerCase();
const rawMessage =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error ?? '');
const normalized = rawMessage.toLowerCase();
console.error('Message send failed:', rawMessage || error);
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')) {
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
if (allAttachments.length > 0) {
useFileStore.setState({ attachedFiles: allAttachments });
}
return;
}
if (isSoftNetworkError) {
return;
}
const isSoftNetworkError =
normalized.includes('timeout') ||
normalized.includes('timed out') ||
normalized.includes('may still be processing') ||
normalized.includes('being processed') ||
normalized.includes('failed to fetch') ||
normalized.includes('networkerror') ||
normalized.includes('network error') ||
normalized.includes('gateway timeout') ||
normalized === 'failed to send message';
if (normalized.includes('payload too large') || normalized.includes('413') || normalized.includes('entity too large')) {
toast.error('Attachments are too large to send. Please try reducing the number or size of images.');
if (allAttachments.length > 0) {
useFileStore.setState({ attachedFiles: allAttachments });
}
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
});
return;
}
if (isSoftNetworkError) {
return;
}
if (allAttachments.length > 0) {
useFileStore.setState({ attachedFiles: allAttachments });
}
toast.error(rawMessage || 'Message failed to send. Attachments restored.');
});
if (!isMobile) {
textareaRef.current?.focus();
@@ -510,7 +465,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
React.useEffect(() => {
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown';
const isNowIdle = sessionPhase === 'idle';
// Check if session was recently aborted (within last 2 seconds)
const wasRecentlyAborted = currentSessionId && sessionAbortFlags.has(currentSessionId) && (() => {
const abortRecord = sessionAbortFlags.get(currentSessionId);
@@ -518,12 +473,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const timeSinceAbort = Date.now() - abortRecord.timestamp;
return timeSinceAbort < 2000;
})();
// Detect transition from working to idle, but skip if aborted
if (wasWorking && isNowIdle && queuedMessages.length > 0 && !autoSendTriggeredRef.current && !wasRecentlyAborted) {
// Prevent double-triggering
autoSendTriggeredRef.current = true;
// Use setTimeout to avoid calling during render
setTimeout(() => {
if (currentSessionId && currentProviderId && currentModelId) {
@@ -532,7 +487,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
autoSendTriggeredRef.current = false;
}, 100);
}
prevSessionPhaseRef.current = sessionPhase;
}, [sessionPhase, queuedMessages.length, currentSessionId, currentProviderId, currentModelId, sessionAbortFlags]);
@@ -582,15 +537,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
// Handle Enter/Ctrl+Enter based on queue mode
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey;
// 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
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
if (queueModeEnabled) {
if (isCtrlEnter || !canQueue) {
// Ctrl+Enter sends, or Enter when can't queue (new session)
@@ -699,6 +654,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (cursorPosition <= commandEnd && firstSpace === -1) {
const commandText = value.substring(1, commandEnd);
setCommandQuery(commandText);
setAutocompleteTab('commands');
setShowCommandAutocomplete(true);
setShowFileMention(false);
setShowAgentAutocomplete(false);
@@ -720,6 +676,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (isWordBoundary && !hasSeparator) {
setAgentQuery(textAfterHash);
setAutocompleteTab('agents');
setShowAgentAutocomplete(true);
setShowFileMention(false);
return;
@@ -753,6 +710,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
setMentionQuery(textAfterAt);
setAutocompleteTab('files');
setShowFileMention(true);
} else {
setShowFileMention(false);
@@ -760,7 +718,86 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
} else {
setShowFileMention(false);
}
}, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
}, [setAgentQuery, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
const applyAutocompletePrefix = React.useCallback((prefix: '/' | '#' | '@') => {
const nextMessage = message.length === 0
? prefix
: (message[0] === '/' || message[0] === '#' || message[0] === '@')
? `${prefix}${message.slice(1)}`
: `${prefix}${message}`;
setMessage(nextMessage);
requestAnimationFrame(() => {
if (textareaRef.current) {
const nextCursor = Math.min(nextMessage.length, textareaRef.current.value.length);
textareaRef.current.selectionStart = nextCursor;
textareaRef.current.selectionEnd = nextCursor;
}
adjustTextareaHeight();
updateAutocompleteState(nextMessage, nextMessage.length);
});
}, [adjustTextareaHeight, message, setMessage, updateAutocompleteState]);
const handleAutocompleteTabSelect = React.useCallback((tab: 'commands' | 'agents' | 'files') => {
const textarea = textareaRef.current;
if (isMobile && textarea) {
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
const len = textarea.value.length;
try {
textarea.setSelectionRange(len, len);
} catch {
// ignored
}
}
setAutocompleteTab(tab);
setCommandQuery('');
setAgentQuery('');
setMentionQuery('');
if (tab === 'commands') {
applyAutocompletePrefix('/');
}
if (tab === 'agents') {
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]);
const handleOpenCommandMenu = React.useCallback(() => {
if (!isMobile) {
return;
}
const textarea = textareaRef.current;
if (textarea) {
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
const len = textarea.value.length;
try {
textarea.setSelectionRange(len, len);
} catch {
// ignored
}
}
applyAutocompletePrefix('/');
setCommandQuery('');
setAutocompleteTab('commands');
setShowCommandAutocomplete(true);
setShowAgentAutocomplete(false);
setShowFileMention(false);
setShowSkillAutocomplete(false);
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
const insertTextAtSelection = React.useCallback((text: string) => {
if (!text) {
@@ -869,6 +906,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
file.name +
message.substring(cursorPosition);
setMessage(newMessage);
} else if (textareaRef.current) {
const newMessage =
message.substring(0, cursorPosition) +
`@${file.name} ` +
message.substring(cursorPosition);
setMessage(newMessage);
const nextCursor = cursorPosition + file.name.length + 2;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
textareaRef.current.selectionEnd = nextCursor;
}
adjustTextareaHeight();
updateAutocompleteState(newMessage, nextCursor);
});
}
setShowFileMention(false);
@@ -899,6 +951,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
adjustTextareaHeight();
updateAutocompleteState(newMessage, nextCursor);
});
} else if (textareaRef.current) {
const newMessage =
message.substring(0, cursorPosition) +
`#${agentName} ` +
message.substring(cursorPosition);
setMessage(newMessage);
const nextCursor = cursorPosition + agentName.length + 2;
requestAnimationFrame(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = nextCursor;
textareaRef.current.selectionEnd = nextCursor;
}
adjustTextareaHeight();
updateAutocompleteState(newMessage, nextCursor);
});
}
setShowAgentAutocomplete(false);
@@ -949,12 +1017,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setShowCommandAutocomplete(false);
setCommandQuery('');
setTimeout(() => {
const refocus = () => {
if (textareaRef.current) {
textareaRef.current.focus();
try {
textareaRef.current.focus({ preventScroll: true });
} catch {
textareaRef.current.focus();
}
textareaRef.current.setSelectionRange(textareaRef.current.value.length, textareaRef.current.value.length);
}
}, 0);
};
requestAnimationFrame(() => {
refocus();
requestAnimationFrame(refocus);
});
setTimeout(refocus, 60);
};
React.useEffect(() => {
@@ -1135,7 +1213,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
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');
const iconSizeClass = isMobile ? 'h-5 w-5' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
const iconSizeClass = isMobile ? 'h-[18px] w-[18px]' : (isVSCode ? 'h-4 w-4' : 'h-[18px] w-[18px]');
const iconButtonBaseClass = 'flex items-center justify-center text-muted-foreground transition-none outline-none focus:outline-none flex-shrink-0';
@@ -1332,6 +1410,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const attachmentsControls = (
<>
{isMobile ? (
<button
type="button"
className={cn(
iconButtonBaseClass,
'h-7 w-7 rounded-md border border-transparent typography-ui-label font-semibold text-muted-foreground',
'hover:bg-interactive-hover/40 hover:text-foreground'
)}
onClick={handleOpenCommandMenu}
title="Commands"
aria-label="Commands"
>
<RiCommandLine className={cn(iconSizeClass)} />
</button>
) : null}
{attachmentMenu}
{settingsButton}
</>
@@ -1428,26 +1521,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}, 0);
}}
/>
{/* Review comments chip */}
{hasDrafts && (
<div className="pb-2">
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-xl border"
style={{
backgroundColor: currentTheme?.colors?.surface?.elevated,
borderColor: currentTheme?.colors?.interactive?.border,
}}
>
<span className="text-xs font-medium text-muted-foreground">Review comments:</span>
<span
className="text-xs font-semibold"
style={{ color: currentTheme?.colors?.status?.info }}
>
{draftCount}
</span>
</div>
</div>
)}
<div
className={cn(
"flex flex-col relative overflow-visible",
@@ -1459,55 +1532,68 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
backgroundColor: currentTheme?.colors?.surface?.subtle,
}}
>
{showCommandAutocomplete && (
<CommandAutocomplete
ref={commandRef}
searchQuery={commandQuery}
onCommandSelect={handleCommandSelect}
showTabs={isMobile}
activeTab={autocompleteTab}
onTabSelect={handleAutocompleteTabSelect}
onClose={() => setShowCommandAutocomplete(false)}
/>
)}
{}
{ }
{showAgentAutocomplete && (
<AgentMentionAutocomplete
ref={agentRef}
searchQuery={agentQuery}
onAgentSelect={handleAgentSelect}
onClose={() => setShowAgentAutocomplete(false)}
/>
)}
showTabs={isMobile}
activeTab={autocompleteTab}
onTabSelect={handleAutocompleteTabSelect}
onClose={() => setShowAgentAutocomplete(false)}
/>
)}
{showSkillAutocomplete && (
<SkillAutocomplete
ref={skillRef}
searchQuery={skillQuery}
onSkillSelect={handleSkillSelect}
onClose={() => setShowSkillAutocomplete(false)}
/>
)}
{showSkillAutocomplete && (
<SkillAutocomplete
ref={skillRef}
searchQuery={skillQuery}
onSkillSelect={handleSkillSelect}
onClose={() => setShowSkillAutocomplete(false)}
/>
)}
{showFileMention && (
{showFileMention && (
<FileMentionAutocomplete
ref={mentionRef}
searchQuery={mentionQuery}
onFileSelect={handleFileSelect}
showTabs={isMobile}
activeTab={autocompleteTab}
onTabSelect={handleAutocompleteTabSelect}
onClose={() => setShowFileMention(false)}
/>
)}
<Textarea
ref={textareaRef}
data-chat-input="true"
value={message}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onPointerDownCapture={handleTextareaPointerDownCapture}
placeholder={currentSessionId || newSessionDraftOpen
? "# for agents; @ for files; / for commands"
: "Select or create a session to start chatting"}
disabled={!currentSessionId && !newSessionDraftOpen}
outerClassName="focus-within:ring-0"
<Textarea
ref={textareaRef}
data-chat-input="true"
value={message}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onPointerDownCapture={handleTextareaPointerDownCapture}
placeholder={currentSessionId || newSessionDraftOpen
? "# for agents; @ for files; / for commands"
: "Select or create a session to start chatting"}
disabled={!currentSessionId && !newSessionDraftOpen}
autoCorrect={isMobile ? "on" : "off"}
autoCapitalize={isMobile ? "sentences" : "off"}
spellCheck={isMobile}
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',
isMobile ? "py-2.5" : "pt-4 pb-2"
@@ -1535,14 +1621,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
>
{isMobile ? (
<>
<div className="flex w-full items-center gap-x-1.5">
<div className="flex items-center flex-shrink-0 gap-x-1">
<div className="flex w-full items-center gap-x-1">
<div className="flex items-center flex-shrink-0">
{attachmentsControls}
</div>
<div className="flex flex-1 items-center justify-center min-w-0">
<StatusChip onClick={handleOpenMobileControls} className="min-w-0" />
<div className="flex flex-1 items-center min-w-0">
<StatusChip onClick={handleOpenMobileControls} className="min-w-0 max-w-full" />
</div>
<div className="flex-shrink-0">
<div className="flex items-center flex-shrink-0 gap-x-0.5">
{actionButtons}
</div>
</div>
@@ -19,16 +19,24 @@ export interface CommandAutocompleteHandle {
handleKeyDown: (key: string) => void;
}
type AutocompleteTab = 'commands' | 'agents' | 'files';
interface CommandAutocompleteProps {
searchQuery: string;
onCommandSelect: (command: CommandInfo) => void;
onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
onClose: () => void;
showTabs?: boolean;
activeTab?: AutocompleteTab;
onTabSelect?: (tab: AutocompleteTab) => void;
}
export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, CommandAutocompleteProps>(({
searchQuery,
onCommandSelect,
onClose
onClose,
showTabs,
activeTab = 'commands',
onTabSelect
}, ref) => {
const { hasMessagesInCurrentSession, currentSessionId } = useSessionStore(
useShallow((state) => {
@@ -48,6 +56,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const [selectedIndex, setSelectedIndex] = React.useState(0);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false);
const ignoreTabClickRef = React.useRef(false);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
@@ -234,7 +246,47 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
}
event.preventDefault();
event.stopPropagation();
ignoreTabClickRef.current = true;
onTabSelect?.(tab.id);
}}
onClick={() => {
if (ignoreTabClickRef.current) {
ignoreTabClickRef.current = false;
return;
}
onTabSelect?.(tab.id);
}}
>
{tab.label}
</button>
))}
</div>
</div>
) : null}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
{loading ? (
<div className="flex items-center justify-center py-4">
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
@@ -253,7 +305,49 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
"flex items-start gap-2 px-3 py-2 cursor-pointer rounded-lg",
index === selectedIndex && "bg-interactive-selection"
)}
onClick={() => onCommandSelect(command)}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
}
pointerStartRef.current = { x: event.clientX, y: event.clientY };
pointerMovedRef.current = false;
}}
onPointerMove={(event) => {
if (event.pointerType !== 'touch' || !pointerStartRef.current) {
return;
}
const dx = event.clientX - pointerStartRef.current.x;
const dy = event.clientY - pointerStartRef.current.y;
if (Math.hypot(dx, dy) > 6) {
pointerMovedRef.current = true;
}
}}
onPointerUp={(event) => {
if (event.pointerType !== 'touch') {
return;
}
const didMove = pointerMovedRef.current;
pointerStartRef.current = null;
pointerMovedRef.current = false;
if (didMove) {
return;
}
event.preventDefault();
event.stopPropagation();
ignoreClickRef.current = true;
onCommandSelect(command, { dismissKeyboard: true });
}}
onPointerCancel={() => {
pointerStartRef.current = null;
pointerMovedRef.current = false;
}}
onClick={() => {
if (ignoreClickRef.current) {
ignoreClickRef.current = false;
return;
}
onCommandSelect(command);
}}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="mt-0.5">
@@ -16,16 +16,24 @@ export interface FileMentionHandle {
handleKeyDown: (key: string) => void;
}
type AutocompleteTab = 'commands' | 'agents' | 'files';
interface FileMentionAutocompleteProps {
searchQuery: string;
onFileSelect: (file: FileInfo) => void;
onClose: () => void;
showTabs?: boolean;
activeTab?: AutocompleteTab;
onTabSelect?: (tab: AutocompleteTab) => void;
}
export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileMentionAutocompleteProps>(({
searchQuery,
onFileSelect,
onClose
onClose,
showTabs,
activeTab = 'files',
onTabSelect,
}, ref) => {
const { currentDirectory } = useDirectoryStore();
const { addServerFile } = useSessionStore();
@@ -43,6 +51,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const ignoreTabClickRef = React.useRef(false);
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
@@ -317,6 +326,46 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
>
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
}
event.preventDefault();
event.stopPropagation();
ignoreTabClickRef.current = true;
onTabSelect?.(tab.id);
}}
onClick={() => {
if (ignoreTabClickRef.current) {
ignoreTabClickRef.current = false;
return;
}
onTabSelect?.(tab.id);
}}
>
{tab.label}
</button>
))}
</div>
</div>
) : null}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
{loading ? (
<div className="flex items-center justify-center py-4">
+106 -28
View File
@@ -3,6 +3,7 @@ import type { ComponentType } from 'react';
import {
RiAiAgentLine,
RiArrowDownSLine,
RiArrowGoBackLine,
RiArrowRightSLine,
RiBrainAi3Line,
RiCheckLine,
@@ -270,6 +271,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentVariant,
currentAgentName,
settingsDefaultVariant,
settingsDefaultAgent,
setProvider,
setModel,
setCurrentVariant,
@@ -336,6 +338,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
isModelSelectorOpen,
setModelSelectorOpen,
} = useUIStore();
// Separate state for agent selector to avoid conflict with model selector
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile } = useDeviceInfo();
@@ -385,12 +390,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
}, [activeMobilePanel]);
const prevAgentMenuOpenRef = React.useRef(agentMenuOpen);
// Handle model selector close behavior (separate from agent selector)
const prevModelSelectorOpenRef = React.useRef(isModelSelectorOpen);
React.useEffect(() => {
const wasOpen = prevAgentMenuOpenRef.current;
prevAgentMenuOpenRef.current = agentMenuOpen;
const wasOpen = prevModelSelectorOpenRef.current;
prevModelSelectorOpenRef.current = isModelSelectorOpen;
if (!agentMenuOpen) {
if (!isModelSelectorOpen) {
setDesktopModelQuery('');
setModelSelectedIndex(0);
@@ -402,13 +408,48 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
});
}
}
}, [agentMenuOpen, isCompact]);
}, [isModelSelectorOpen, isCompact]);
// Handle agent selector close behavior
const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
React.useEffect(() => {
if (!isAgentSelectorOpen) {
setAgentSearchQuery('');
if (!isCompact) {
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
}
}
}, [isAgentSelectorOpen, isCompact]);
// Reset selected index when search query changes
React.useEffect(() => {
setModelSelectedIndex(0);
}, [desktopModelQuery]);
const sortedAndFilteredAgents = React.useMemo(() => {
const sorted = [...agents].sort((a, b) => a.name.localeCompare(b.name));
if (!agentSearchQuery.trim()) {
return sorted;
}
return sorted.filter((agent) =>
fuzzyMatch(agentSearchQuery, agent.name) ||
(agent.description && fuzzyMatch(agentSearchQuery, agent.description))
);
}, [agents, agentSearchQuery]);
const defaultAgentName = React.useMemo(() => {
if (settingsDefaultAgent) {
const found = agents.find(a => a.name === settingsDefaultAgent);
if (found) return found.name;
}
const buildAgent = agents.find(a => a.name === 'build');
if (buildAgent) return buildAgent.name;
return agents[0]?.name;
}, [settingsDefaultAgent, agents]);
const currentAgent = React.useMemo(() => {
if (uiAgentName) {
return agents.find((agent) => agent.name === uiAgentName);
@@ -2408,7 +2449,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return (
<div className="flex items-center gap-2 min-w-0">
<Tooltip delayDuration={1000}>
<DropdownMenu>
<DropdownMenu open={isAgentSelectorOpen} onOpenChange={setIsAgentSelectorOpen}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div className={cn(
@@ -2437,29 +2478,66 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))]">
{agents.filter(agent => isPrimaryMode(agent.mode)).map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
getAgentColor(agent.name).class
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<div className="p-2 border-b border-border/40">
<div className="relative">
<RiSearchLine className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search agents"
value={agentSearchQuery}
onChange={(e) => setAgentSearchQuery(e.target.value)}
className="pl-8 h-8 typography-meta"
autoFocus
/>
</div>
</div>
<ScrollableOverlay outerClassName="max-h-[min(400px,calc(100dvh-12rem))] flex-1">
<div className="p-1">
{!agentSearchQuery.trim() && defaultAgentName && (
<>
<DropdownMenuItem
className="typography-meta"
onSelect={() => handleAgentChange(defaultAgentName)}
>
<div className="flex items-center gap-1.5">
<RiArrowGoBackLine className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">Reset to default</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{sortedAndFilteredAgents.length === 0 ? (
<div className="px-2 py-4 text-center typography-meta text-muted-foreground">
No agents found
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
))}
) : (
sortedAndFilteredAgents.map((agent) => (
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
getAgentColor(agent.name).class
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
))
)}
</div>
</ScrollableOverlay>
<DropdownMenuSeparator />
<div className="flex flex-col gap-1 px-1 py-0.5">
<div className="rounded-xl bg-transparent">
@@ -39,24 +39,24 @@ export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) =>
onClick={onClick}
className={cn(
'inline-flex min-w-0 items-center justify-center',
'rounded-lg border border-border/50 px-1.5',
'typography-meta font-medium text-foreground/80',
'rounded-md border border-border/50 px-1.5',
'text-[11px] font-medium text-foreground/80',
'focus:outline-none hover:bg-[var(--interactive-hover)]',
className
)}
style={{
height: '30px',
maxHeight: '30px',
minHeight: '30px',
height: '28px',
maxHeight: '28px',
minHeight: '28px',
}}
title={fullLabel}
>
<span className="shrink-0">{agentLabel}</span>
<span className="shrink-0 text-muted-foreground mx-1">·</span>
<span className="shrink-0 text-muted-foreground mx-0.5">·</span>
<span className="min-w-0 truncate">{modelLabel}</span>
{effortLabel && (
<>
<span className="shrink-0 text-muted-foreground mx-1">·</span>
<span className="shrink-0 text-muted-foreground mx-0.5">·</span>
<span className="shrink-0">{effortLabel}</span>
</>
)}
@@ -853,15 +853,17 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
autoCorrect="off"
spellCheck={false}
tabIndex={-1}
aria-hidden="true"
enterKeyHint="send"
style={{
position: 'absolute',
left: 0,
top: 0,
width: 1,
height: 1,
opacity: 0,
zIndex: 1,
// Android IME needs visible, larger dimensions to properly attach
width: 30,
height: 30,
// Must be slightly visible for Android IME - clip to hide visually
opacity: 0.011,
zIndex: 10,
background: 'transparent',
color: 'transparent',
caretColor: 'transparent',
@@ -872,15 +874,32 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
padding: 0,
margin: 0,
outline: 'none',
// Prevent iOS zoom on focus - must be 16px+
fontSize: 16,
// Ensure pointer events work
pointerEvents: 'auto',
// Android needs these for IME to work properly
WebkitUserSelect: 'text',
userSelect: 'text',
// Transform off-screen but keep focusable
transform: 'translateX(-9999px)',
transformOrigin: 'left top',
}}
onFocus={(event) => {
// Move back on screen when focused (for Android IME)
event.currentTarget.style.transform = 'translateX(0)';
}}
onBlur={(event) => {
// Move off screen when blurred
event.currentTarget.style.transform = 'translateX(-9999px)';
}}
onBeforeInput={(event) => {
const nativeEvent = event.nativeEvent as unknown as InputEvent | undefined;
const inputType = nativeEvent?.inputType ?? '';
const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : '';
// Android Chrome sometimes never commits text into the DOM value
// for invisible inputs. Use beforeinput as the primary path.
if (inputType === 'insertText' && data) {
// Handle insertText (iOS) and insertCompositionText (Android Gboard)
if ((inputType === 'insertText' || inputType === 'insertCompositionText') && data) {
event.preventDefault();
inputHandlerRef.current(data);
return;
@@ -898,17 +917,25 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
}}
onInput={(event) => {
// Fallback: capture any text that makes it through to the input value
const raw = String(event.currentTarget.value || '');
if (!raw) {
return;
}
// Some mobile keyboards (iOS esp.) insert `\n` for Enter; PTY expects CR.
// Some mobile keyboards insert `\n` for Enter; PTY expects CR.
const value = raw.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value);
event.currentTarget.value = '';
}}
onKeyDown={(event) => {
// Handle Enter key explicitly for Android
if (event.key === 'Enter') {
event.preventDefault();
inputHandlerRef.current('\r');
event.currentTarget.value = '';
return;
}
if (event.key === 'Backspace') {
// If there's nothing in the input buffer, emulate DEL.
if (!event.currentTarget.value) {
@@ -916,6 +943,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
}
}}
onCompositionEnd={(event) => {
// Android IME sends final text via composition events
const data = event.data;
if (data) {
inputHandlerRef.current(data);
event.currentTarget.value = '';
}
}}
/>
) : null}
{viewportRef.current && !enableTouchScroll ? (
+69
View File
@@ -0,0 +1,69 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
className?: string;
label?: string;
valueFormatter?: (value: number) => string;
}
/**
* Range slider component for numeric input
* Uses native range input styled with Tailwind CSS
*/
export const Slider: React.FC<SliderProps> = ({
value,
onChange,
min = 0,
max = 1,
step = 0.1,
disabled = false,
className,
label,
valueFormatter = (v) => v.toFixed(1),
}) => {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className={cn('flex items-center gap-3', className)}>
<div className="flex-1 relative">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
disabled={disabled}
className={cn(
'w-full h-2 rounded-full appearance-none cursor-pointer bg-muted-foreground/20',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4',
'[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary',
'[&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:transition-transform',
'[&::-webkit-slider-thumb]:hover:scale-110 [&::-webkit-slider-thumb]:active:scale-95',
'[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full',
'[&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-md'
)}
style={{
background: `linear-gradient(to right, hsl(var(--primary)) ${percentage}%, hsl(var(--muted-foreground) / 0.2) ${percentage}%)`,
}}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
/>
</div>
<span className="typography-mono text-xs text-muted-foreground min-w-[3ch] text-right">
{valueFormatter(value)}
</span>
</div>
);
};