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:
@@ -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))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user