- Include base64 data in file metadata for chat display (fixes broken images) - Use file-to-disk + Read tool approach instead of SDK multimodal messages (fixes slow response caused by invalid prompt format) - Move attach button before folder picker in input toolbar Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
892 lines
31 KiB
TypeScript
892 lines
31 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent } from 'react';
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
import {
|
|
AtIcon,
|
|
DivideSignIcon,
|
|
FolderOpenIcon,
|
|
Wrench01Icon,
|
|
ClipboardIcon,
|
|
HelpCircleIcon,
|
|
ArrowDown01Icon,
|
|
CommandLineIcon,
|
|
Attachment01Icon,
|
|
Cancel01Icon,
|
|
} from "@hugeicons/core-free-icons";
|
|
import { cn } from '@/lib/utils';
|
|
import { FolderPicker } from './FolderPicker';
|
|
import {
|
|
PromptInput,
|
|
PromptInputTextarea,
|
|
PromptInputFooter,
|
|
PromptInputTools,
|
|
PromptInputButton,
|
|
PromptInputSubmit,
|
|
usePromptInputAttachments,
|
|
} from '@/components/ai-elements/prompt-input';
|
|
import type { ChatStatus } from 'ai';
|
|
import type { FileAttachment } from '@/types';
|
|
import { nanoid } from 'nanoid';
|
|
|
|
// Accepted file types for upload
|
|
const ACCEPTED_FILE_TYPES = [
|
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
|
'application/pdf',
|
|
'text/*',
|
|
'.md', '.json', '.csv', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs',
|
|
].join(',');
|
|
|
|
// Max file sizes
|
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
|
|
const MAX_FILE_SIZE = MAX_DOC_SIZE; // Use larger limit; we validate per-type in conversion
|
|
|
|
interface MessageInputProps {
|
|
onSend: (content: string, files?: FileAttachment[]) => void;
|
|
onCommand?: (command: string) => void;
|
|
onStop?: () => void;
|
|
disabled?: boolean;
|
|
isStreaming?: boolean;
|
|
sessionId?: string;
|
|
modelName?: string;
|
|
onModelChange?: (model: string) => void;
|
|
workingDirectory?: string;
|
|
onWorkingDirectoryChange?: (dir: string) => void;
|
|
mode?: string;
|
|
onModeChange?: (mode: string) => void;
|
|
}
|
|
|
|
interface PopoverItem {
|
|
label: string;
|
|
value: string;
|
|
description?: string;
|
|
builtIn?: boolean;
|
|
immediate?: boolean;
|
|
}
|
|
|
|
interface CommandBadge {
|
|
command: string;
|
|
label: string;
|
|
description: string;
|
|
isSkill: boolean;
|
|
}
|
|
|
|
type PopoverMode = 'file' | 'skill' | null;
|
|
|
|
// Expansion prompts for CLI-only commands (not natively supported by SDK).
|
|
// SDK-native commands (/compact, /init, /review) are sent as-is — the SDK handles them directly.
|
|
const COMMAND_PROMPTS: Record<string, string> = {
|
|
'/doctor': 'Run diagnostic checks on this project. Check system health, dependencies, configuration files, and report any issues.',
|
|
'/terminal-setup': 'Help me configure my terminal for optimal use with Claude Code. Check current setup and suggest improvements.',
|
|
'/memory': 'Show the current CLAUDE.md project memory file and help me review or edit it.',
|
|
};
|
|
|
|
const BUILT_IN_COMMANDS: PopoverItem[] = [
|
|
{ label: 'help', value: '/help', description: 'Show available commands and tips', builtIn: true, immediate: true },
|
|
{ label: 'clear', value: '/clear', description: 'Clear conversation history', builtIn: true, immediate: true },
|
|
{ label: 'cost', value: '/cost', description: 'Show token usage statistics', builtIn: true, immediate: true },
|
|
{ label: 'compact', value: '/compact', description: 'Compress conversation context', builtIn: true },
|
|
{ label: 'doctor', value: '/doctor', description: 'Diagnose project health', builtIn: true },
|
|
{ label: 'init', value: '/init', description: 'Initialize CLAUDE.md for project', builtIn: true },
|
|
{ label: 'review', value: '/review', description: 'Review code quality', builtIn: true },
|
|
{ label: 'terminal-setup', value: '/terminal-setup', description: 'Configure terminal settings', builtIn: true },
|
|
{ label: 'memory', value: '/memory', description: 'Edit project memory file', builtIn: true },
|
|
];
|
|
|
|
interface ModeOption {
|
|
value: string;
|
|
label: string;
|
|
icon: typeof Wrench01Icon;
|
|
description: string;
|
|
}
|
|
|
|
const MODE_OPTIONS: ModeOption[] = [
|
|
{ value: 'code', label: 'Code', icon: Wrench01Icon, description: 'Read, write files & run commands' },
|
|
{ value: 'plan', label: 'Plan', icon: ClipboardIcon, description: 'Analyze & plan without executing' },
|
|
{ value: 'ask', label: 'Ask', icon: HelpCircleIcon, description: 'Answer questions only' },
|
|
];
|
|
|
|
// Default Claude model options — labels are dynamically overridden by active provider
|
|
const DEFAULT_MODEL_OPTIONS = [
|
|
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
|
{ value: 'opus', label: 'Opus 4.6' },
|
|
{ value: 'haiku', label: 'Haiku 4.5' },
|
|
];
|
|
|
|
// Provider-specific model label mappings (alias → display name)
|
|
const PROVIDER_MODEL_LABELS: Record<string, Record<string, string>> = {
|
|
// GLM Coding Plan (Z.AI / 智谱)
|
|
'https://api.z.ai/api/anthropic': {
|
|
sonnet: 'GLM-4.7',
|
|
opus: 'GLM-4.7',
|
|
haiku: 'GLM-4.5-Air',
|
|
},
|
|
'https://open.bigmodel.cn/api/anthropic': {
|
|
sonnet: 'GLM-4.7',
|
|
opus: 'GLM-4.7',
|
|
haiku: 'GLM-4.5-Air',
|
|
},
|
|
// Kimi Coding Plan
|
|
'https://api.kimi.com/coding/': {
|
|
sonnet: 'Kimi K2.5',
|
|
opus: 'Kimi K2.5',
|
|
haiku: 'Kimi K2.5',
|
|
},
|
|
// Moonshot Open Platform
|
|
'https://api.moonshot.ai/anthropic': {
|
|
sonnet: 'Kimi K2.5',
|
|
opus: 'Kimi K2.5',
|
|
haiku: 'Kimi K2.5',
|
|
},
|
|
'https://api.moonshot.cn/anthropic': {
|
|
sonnet: 'Kimi K2.5',
|
|
opus: 'Kimi K2.5',
|
|
haiku: 'Kimi K2.5',
|
|
},
|
|
// MiniMax Coding Plan
|
|
'https://api.minimaxi.com/anthropic': {
|
|
sonnet: 'MiniMax-M2.1',
|
|
opus: 'MiniMax-M2.1',
|
|
haiku: 'MiniMax-M2.1',
|
|
},
|
|
'https://api.minimax.io/anthropic': {
|
|
sonnet: 'MiniMax-M2.1',
|
|
opus: 'MiniMax-M2.1',
|
|
haiku: 'MiniMax-M2.1',
|
|
},
|
|
// OpenRouter — keeps Claude names, provider handles routing
|
|
'https://openrouter.ai/api': {
|
|
sonnet: 'Sonnet 4.5',
|
|
opus: 'Opus 4.6',
|
|
haiku: 'Haiku 4.5',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Convert a data URL to a FileAttachment object.
|
|
*/
|
|
async function dataUrlToFileAttachment(
|
|
dataUrl: string,
|
|
filename: string,
|
|
mediaType: string,
|
|
): Promise<FileAttachment> {
|
|
// data:image/png;base64,<data> — extract the base64 part
|
|
const base64 = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl;
|
|
|
|
// Estimate raw size from base64 length
|
|
const size = Math.ceil((base64.length * 3) / 4);
|
|
|
|
return {
|
|
id: nanoid(),
|
|
name: filename,
|
|
type: mediaType || 'application/octet-stream',
|
|
size,
|
|
data: base64,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Submit button that's aware of file attachments. Must be rendered inside PromptInput.
|
|
*/
|
|
function FileAwareSubmitButton({
|
|
status,
|
|
onStop,
|
|
disabled,
|
|
inputValue,
|
|
hasBadge,
|
|
}: {
|
|
status: ChatStatus;
|
|
onStop?: () => void;
|
|
disabled?: boolean;
|
|
inputValue: string;
|
|
hasBadge: boolean;
|
|
}) {
|
|
const attachments = usePromptInputAttachments();
|
|
const hasFiles = attachments.files.length > 0;
|
|
const isStreaming = status === 'streaming' || status === 'submitted';
|
|
|
|
return (
|
|
<PromptInputSubmit
|
|
status={status}
|
|
onStop={onStop}
|
|
disabled={disabled || (!isStreaming && !inputValue.trim() && !hasBadge && !hasFiles)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Attachment button that opens the file dialog. Must be rendered inside PromptInput.
|
|
*/
|
|
function AttachFileButton() {
|
|
const attachments = usePromptInputAttachments();
|
|
|
|
return (
|
|
<PromptInputButton
|
|
onClick={() => attachments.openFileDialog()}
|
|
tooltip="Attach files"
|
|
>
|
|
<HugeiconsIcon icon={Attachment01Icon} className="h-3.5 w-3.5" />
|
|
</PromptInputButton>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Capsule display for attached files, rendered inside PromptInput context.
|
|
*/
|
|
function FileAttachmentsCapsules() {
|
|
const attachments = usePromptInputAttachments();
|
|
|
|
if (attachments.files.length === 0) return null;
|
|
|
|
return (
|
|
<div className="flex w-full flex-wrap items-center gap-1.5 px-3 pt-2 pb-0 order-first">
|
|
{attachments.files.map((file) => {
|
|
const isImage = file.mediaType?.startsWith('image/');
|
|
return (
|
|
<span
|
|
key={file.id}
|
|
className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 pl-2 pr-1 py-0.5 text-xs font-medium border border-emerald-500/20"
|
|
>
|
|
{isImage && file.url && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={file.url}
|
|
alt={file.filename || 'image'}
|
|
className="h-5 w-5 rounded object-cover"
|
|
/>
|
|
)}
|
|
<span className="max-w-[120px] truncate text-[11px]">
|
|
{file.filename || 'file'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => attachments.remove(file.id)}
|
|
className="ml-0.5 rounded-full p-0.5 hover:bg-emerald-500/20 transition-colors"
|
|
>
|
|
<HugeiconsIcon icon={Cancel01Icon} className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MessageInput({
|
|
onSend,
|
|
onCommand,
|
|
onStop,
|
|
disabled,
|
|
isStreaming,
|
|
sessionId,
|
|
modelName,
|
|
onModelChange,
|
|
workingDirectory,
|
|
onWorkingDirectoryChange,
|
|
mode = 'code',
|
|
onModeChange,
|
|
}: MessageInputProps) {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const modeMenuRef = useRef<HTMLDivElement>(null);
|
|
const modelMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [popoverMode, setPopoverMode] = useState<PopoverMode>(null);
|
|
const [popoverItems, setPopoverItems] = useState<PopoverItem[]>([]);
|
|
const [popoverFilter, setPopoverFilter] = useState('');
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [triggerPos, setTriggerPos] = useState<number | null>(null);
|
|
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
|
|
const [modeMenuOpen, setModeMenuOpen] = useState(false);
|
|
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [badge, setBadge] = useState<CommandBadge | null>(null);
|
|
const [activeProviderBaseUrl, setActiveProviderBaseUrl] = useState<string | null>(null);
|
|
const [activeProviderName, setActiveProviderName] = useState<string | null>(null);
|
|
|
|
// Fetch active provider to adapt model labels
|
|
useEffect(() => {
|
|
fetch('/api/providers')
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
const active = (data.providers || []).find((p: { is_active: number }) => p.is_active === 1);
|
|
if (active) {
|
|
setActiveProviderBaseUrl(active.base_url || null);
|
|
setActiveProviderName(active.name || null);
|
|
} else {
|
|
setActiveProviderBaseUrl(null);
|
|
setActiveProviderName(null);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// Compute model options based on active provider
|
|
const MODEL_OPTIONS = DEFAULT_MODEL_OPTIONS.map((opt) => {
|
|
if (activeProviderBaseUrl && PROVIDER_MODEL_LABELS[activeProviderBaseUrl]) {
|
|
const label = PROVIDER_MODEL_LABELS[activeProviderBaseUrl][opt.value];
|
|
if (label) return { ...opt, label };
|
|
}
|
|
return opt;
|
|
});
|
|
|
|
// Fetch files for @ mention
|
|
const fetchFiles = useCallback(async (filter: string) => {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (sessionId) params.set('session_id', sessionId);
|
|
if (filter) params.set('q', filter);
|
|
const res = await fetch(`/api/files?${params.toString()}`);
|
|
if (!res.ok) return [];
|
|
const data = await res.json();
|
|
const tree = data.tree || [];
|
|
const items: PopoverItem[] = [];
|
|
function flattenTree(nodes: Array<{ name: string; path: string; type: string; children?: unknown[] }>) {
|
|
for (const node of nodes) {
|
|
items.push({ label: node.name, value: node.path });
|
|
if (node.children) flattenTree(node.children as typeof nodes);
|
|
}
|
|
}
|
|
flattenTree(tree);
|
|
return items.slice(0, 20);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}, [sessionId]);
|
|
|
|
// Fetch skills for / command (built-in + API)
|
|
const fetchSkills = useCallback(async (filter: string) => {
|
|
const builtIn = BUILT_IN_COMMANDS.filter((cmd) =>
|
|
cmd.label.toLowerCase().includes(filter.toLowerCase())
|
|
);
|
|
|
|
let apiSkills: PopoverItem[] = [];
|
|
try {
|
|
const res = await fetch('/api/skills');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const skills = data.skills || [];
|
|
apiSkills = skills
|
|
.filter((s: { name: string }) => s.name.toLowerCase().includes(filter.toLowerCase()))
|
|
.map((s: { name: string; description: string }) => ({
|
|
label: s.name,
|
|
value: `/${s.name}`,
|
|
description: s.description,
|
|
builtIn: false,
|
|
}));
|
|
}
|
|
} catch {
|
|
// API not available - just use built-in commands
|
|
}
|
|
|
|
return [...builtIn, ...apiSkills].slice(0, 20);
|
|
}, []);
|
|
|
|
// Close popover
|
|
const closePopover = useCallback(() => {
|
|
setPopoverMode(null);
|
|
setPopoverItems([]);
|
|
setPopoverFilter('');
|
|
setSelectedIndex(0);
|
|
setTriggerPos(null);
|
|
}, []);
|
|
|
|
// Remove active badge
|
|
const removeBadge = useCallback(() => {
|
|
setBadge(null);
|
|
setTimeout(() => textareaRef.current?.focus(), 0);
|
|
}, []);
|
|
|
|
// Insert selected item
|
|
const insertItem = useCallback((item: PopoverItem) => {
|
|
if (triggerPos === null) return;
|
|
|
|
// Immediate built-in commands: execute right away
|
|
if (item.builtIn && item.immediate && onCommand) {
|
|
setInputValue('');
|
|
closePopover();
|
|
onCommand(item.value);
|
|
return;
|
|
}
|
|
|
|
// Non-immediate commands (prompt-based built-ins and skills): show as badge
|
|
if (popoverMode === 'skill') {
|
|
setBadge({
|
|
command: item.value,
|
|
label: item.label,
|
|
description: item.description || '',
|
|
isSkill: !item.builtIn,
|
|
});
|
|
setInputValue('');
|
|
closePopover();
|
|
setTimeout(() => textareaRef.current?.focus(), 0);
|
|
return;
|
|
}
|
|
|
|
// File mention: insert into text
|
|
const currentVal = inputValue;
|
|
const before = currentVal.slice(0, triggerPos);
|
|
const cursorEnd = triggerPos + popoverFilter.length + 1;
|
|
const after = currentVal.slice(cursorEnd);
|
|
const insertText = `@${item.value} `;
|
|
|
|
setInputValue(before + insertText + after);
|
|
closePopover();
|
|
|
|
// Refocus textarea
|
|
setTimeout(() => textareaRef.current?.focus(), 0);
|
|
}, [triggerPos, popoverMode, closePopover, onCommand, inputValue, popoverFilter]);
|
|
|
|
// Handle input changes to detect @ and /
|
|
const handleInputChange = useCallback(async (val: string) => {
|
|
setInputValue(val);
|
|
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
|
|
const cursorPos = textarea.selectionStart;
|
|
const beforeCursor = val.slice(0, cursorPos);
|
|
|
|
// Check for @ trigger
|
|
const atMatch = beforeCursor.match(/@([^\s@]*)$/);
|
|
if (atMatch) {
|
|
const filter = atMatch[1];
|
|
setPopoverMode('file');
|
|
setPopoverFilter(filter);
|
|
setTriggerPos(cursorPos - atMatch[0].length);
|
|
setSelectedIndex(0);
|
|
const items = await fetchFiles(filter);
|
|
setPopoverItems(items);
|
|
return;
|
|
}
|
|
|
|
// Check for / trigger (only at start of line or after space)
|
|
const slashMatch = beforeCursor.match(/(^|\s)\/([^\s]*)$/);
|
|
if (slashMatch) {
|
|
const filter = slashMatch[2];
|
|
setPopoverMode('skill');
|
|
setPopoverFilter(filter);
|
|
setTriggerPos(cursorPos - slashMatch[2].length - 1);
|
|
setSelectedIndex(0);
|
|
const items = await fetchSkills(filter);
|
|
setPopoverItems(items);
|
|
return;
|
|
}
|
|
|
|
if (popoverMode) {
|
|
closePopover();
|
|
}
|
|
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
|
|
|
|
const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const content = inputValue.trim();
|
|
|
|
closePopover();
|
|
|
|
// Convert PromptInput FileUIParts (with data URLs) to FileAttachment[]
|
|
const convertFiles = async (): Promise<FileAttachment[]> => {
|
|
if (!msg.files || msg.files.length === 0) return [];
|
|
|
|
const attachments: FileAttachment[] = [];
|
|
for (const file of msg.files) {
|
|
if (!file.url) continue;
|
|
try {
|
|
const attachment = await dataUrlToFileAttachment(
|
|
file.url,
|
|
file.filename || 'file',
|
|
file.mediaType || 'application/octet-stream',
|
|
);
|
|
// Enforce per-type size limits
|
|
const isImage = attachment.type.startsWith('image/');
|
|
const sizeLimit = isImage ? MAX_IMAGE_SIZE : MAX_DOC_SIZE;
|
|
if (attachment.size <= sizeLimit) {
|
|
attachments.push(attachment);
|
|
}
|
|
} catch {
|
|
// Skip files that fail conversion
|
|
}
|
|
}
|
|
return attachments;
|
|
};
|
|
|
|
// If badge is active, expand the command/skill and send
|
|
if (badge) {
|
|
let expandedPrompt = '';
|
|
|
|
if (badge.isSkill) {
|
|
// Fetch skill content from API
|
|
try {
|
|
const res = await fetch(`/api/skills/${encodeURIComponent(badge.label)}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
expandedPrompt = data.skill?.content || '';
|
|
}
|
|
} catch {
|
|
// Fallback: use command name
|
|
}
|
|
} else {
|
|
// Built-in prompt command expansion
|
|
expandedPrompt = COMMAND_PROMPTS[badge.command] || '';
|
|
}
|
|
|
|
const finalPrompt = content
|
|
? `${expandedPrompt}\n\nUser context: ${content}`
|
|
: expandedPrompt || badge.command;
|
|
|
|
const files = await convertFiles();
|
|
setBadge(null);
|
|
setInputValue('');
|
|
onSend(finalPrompt, files.length > 0 ? files : undefined);
|
|
return;
|
|
}
|
|
|
|
const files = await convertFiles();
|
|
const hasFiles = files.length > 0;
|
|
|
|
if ((!content && !hasFiles) || disabled) return;
|
|
|
|
// Check if it's a direct slash command typed in the input
|
|
if (content.startsWith('/') && !hasFiles) {
|
|
const cmd = BUILT_IN_COMMANDS.find(c => c.value === content);
|
|
if (cmd) {
|
|
if (cmd.immediate && onCommand) {
|
|
setInputValue('');
|
|
onCommand(content);
|
|
return;
|
|
}
|
|
// Non-immediate: show as badge for user to add context
|
|
setBadge({
|
|
command: cmd.value,
|
|
label: cmd.label,
|
|
description: cmd.description || '',
|
|
isSkill: false,
|
|
});
|
|
setInputValue('');
|
|
return;
|
|
}
|
|
|
|
// Not a built-in command — treat as a skill
|
|
const skillName = content.slice(1);
|
|
if (skillName) {
|
|
setBadge({
|
|
command: content,
|
|
label: skillName,
|
|
description: '',
|
|
isSkill: true,
|
|
});
|
|
setInputValue('');
|
|
return;
|
|
}
|
|
}
|
|
|
|
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined);
|
|
setInputValue('');
|
|
}, [inputValue, onSend, onCommand, disabled, closePopover, badge]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Popover navigation
|
|
if (popoverMode && popoverItems.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev + 1) % filteredItems.length);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length);
|
|
return;
|
|
}
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (filteredItems[selectedIndex]) {
|
|
insertItem(filteredItems[selectedIndex]);
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closePopover();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Backspace removes badge when input is empty
|
|
if (e.key === 'Backspace' && badge && !inputValue) {
|
|
e.preventDefault();
|
|
removeBadge();
|
|
return;
|
|
}
|
|
|
|
// Escape removes badge
|
|
if (e.key === 'Escape' && badge) {
|
|
e.preventDefault();
|
|
removeBadge();
|
|
return;
|
|
}
|
|
},
|
|
[popoverMode, popoverItems, popoverFilter, selectedIndex, insertItem, closePopover, badge, inputValue, removeBadge]
|
|
);
|
|
|
|
// Click outside to close popover
|
|
useEffect(() => {
|
|
if (!popoverMode) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
|
closePopover();
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [popoverMode, closePopover]);
|
|
|
|
// Click outside to close mode menu
|
|
useEffect(() => {
|
|
if (!modeMenuOpen) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (modeMenuRef.current && !modeMenuRef.current.contains(e.target as Node)) {
|
|
setModeMenuOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [modeMenuOpen]);
|
|
|
|
// Click outside to close model menu
|
|
useEffect(() => {
|
|
if (!modelMenuOpen) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (modelMenuRef.current && !modelMenuRef.current.contains(e.target as Node)) {
|
|
setModelMenuOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [modelMenuOpen]);
|
|
|
|
const filteredItems = popoverItems.filter((item) =>
|
|
item.label.toLowerCase().includes(popoverFilter.toLowerCase())
|
|
);
|
|
|
|
const currentModelValue = modelName || 'sonnet';
|
|
const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0];
|
|
const currentMode = MODE_OPTIONS.find((m) => m.value === mode) || MODE_OPTIONS[0];
|
|
|
|
const folderShortName = workingDirectory
|
|
? workingDirectory.split('/').filter(Boolean).pop() || workingDirectory
|
|
: '';
|
|
|
|
// Map isStreaming to ChatStatus for PromptInputSubmit
|
|
const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready';
|
|
|
|
return (
|
|
<div className="bg-background/80 backdrop-blur-lg px-4 py-3">
|
|
<div className="mx-auto">
|
|
<div className="relative">
|
|
{/* Popover */}
|
|
{popoverMode && filteredItems.length > 0 && (
|
|
<div
|
|
ref={popoverRef}
|
|
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl border bg-popover shadow-lg overflow-hidden z-50"
|
|
>
|
|
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b">
|
|
{popoverMode === 'file' ? 'Files' : 'Commands'}
|
|
</div>
|
|
<div className="max-h-48 overflow-y-auto py-1">
|
|
{filteredItems.map((item, i) => (
|
|
<button
|
|
key={item.value}
|
|
ref={i === selectedIndex ? (el) => { el?.scrollIntoView({ block: 'nearest' }); } : undefined}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors",
|
|
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
|
)}
|
|
onClick={() => insertItem(item)}
|
|
onMouseEnter={() => setSelectedIndex(i)}
|
|
>
|
|
{popoverMode === 'file' ? (
|
|
<HugeiconsIcon icon={AtIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
) : item.builtIn ? (
|
|
<HugeiconsIcon icon={CommandLineIcon} className="h-3.5 w-3.5 shrink-0 text-blue-400" />
|
|
) : (
|
|
<HugeiconsIcon icon={DivideSignIcon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="font-mono text-xs truncate">{item.label}</span>
|
|
{item.builtIn && (
|
|
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium shrink-0">
|
|
Built-in
|
|
</span>
|
|
)}
|
|
{item.description && (
|
|
<span className="ml-auto text-xs text-muted-foreground truncate max-w-[200px]">
|
|
{item.description}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PromptInput replaces the old input area */}
|
|
<PromptInput
|
|
onSubmit={handleSubmit}
|
|
accept={ACCEPTED_FILE_TYPES}
|
|
multiple
|
|
maxFileSize={MAX_FILE_SIZE}
|
|
>
|
|
{/* Command badge */}
|
|
{badge && (
|
|
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 pl-2.5 pr-1.5 py-1 text-xs font-medium border border-blue-500/20">
|
|
<span className="font-mono">{badge.command}</span>
|
|
{badge.description && (
|
|
<span className="text-blue-500/60 dark:text-blue-400/60 text-[10px]">{badge.description}</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={removeBadge}
|
|
className="ml-0.5 rounded-full p-0.5 hover:bg-blue-500/20 transition-colors"
|
|
>
|
|
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M3 3l6 6M9 3l-6 6" />
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
</div>
|
|
)}
|
|
{/* File attachment capsules */}
|
|
<FileAttachmentsCapsules />
|
|
<PromptInputTextarea
|
|
ref={textareaRef}
|
|
placeholder={badge ? "Add details (optional), then press Enter..." : "Message Claude..."}
|
|
value={inputValue}
|
|
onChange={(e) => handleInputChange(e.currentTarget.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={disabled || isStreaming}
|
|
className="min-h-10"
|
|
/>
|
|
<PromptInputFooter>
|
|
<PromptInputTools>
|
|
{/* Attach file button */}
|
|
<AttachFileButton />
|
|
|
|
{/* Folder picker button */}
|
|
<PromptInputButton
|
|
onClick={() => setFolderPickerOpen(true)}
|
|
tooltip={workingDirectory || 'Select project folder'}
|
|
>
|
|
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
|
|
<span className="max-w-[120px] truncate text-xs">
|
|
{folderShortName || 'Folder'}
|
|
</span>
|
|
</PromptInputButton>
|
|
|
|
{/* Mode selector */}
|
|
<div className="relative" ref={modeMenuRef}>
|
|
<PromptInputButton
|
|
onClick={() => setModeMenuOpen((prev) => !prev)}
|
|
>
|
|
<HugeiconsIcon icon={currentMode.icon} className="h-3.5 w-3.5" />
|
|
<span className="text-xs">{currentMode.label}</span>
|
|
<HugeiconsIcon icon={ArrowDown01Icon} className="h-2.5 w-2.5" />
|
|
</PromptInputButton>
|
|
|
|
{/* Mode dropdown */}
|
|
{modeMenuOpen && (
|
|
<div className="absolute bottom-full left-0 mb-1.5 w-56 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
|
|
<div className="py-1">
|
|
{MODE_OPTIONS.map((opt) => {
|
|
const isActive = opt.value === mode;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
|
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
|
)}
|
|
onClick={() => {
|
|
onModeChange?.(opt.value);
|
|
setModeMenuOpen(false);
|
|
}}
|
|
>
|
|
<HugeiconsIcon icon={opt.icon} className="h-4 w-4 shrink-0" />
|
|
<div className="flex flex-col min-w-0">
|
|
<span className="font-medium text-xs">{opt.label}</span>
|
|
<span className="text-[10px] text-muted-foreground truncate">
|
|
{opt.description}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PromptInputTools>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
{/* Model selector */}
|
|
<div className="relative" ref={modelMenuRef}>
|
|
<PromptInputButton
|
|
onClick={() => setModelMenuOpen((prev) => !prev)}
|
|
>
|
|
<span className="text-xs font-mono">{currentModelOption.label}</span>
|
|
<HugeiconsIcon icon={ArrowDown01Icon} className="h-2.5 w-2.5" />
|
|
</PromptInputButton>
|
|
|
|
{modelMenuOpen && (
|
|
<div className="absolute bottom-full right-0 mb-1.5 w-48 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
|
|
<div className="py-1">
|
|
{MODEL_OPTIONS.map((opt) => {
|
|
const isActive = opt.value === currentModelValue;
|
|
return (
|
|
<button
|
|
key={opt.value}
|
|
className={cn(
|
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors",
|
|
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
|
)}
|
|
onClick={() => {
|
|
onModelChange?.(opt.value);
|
|
setModelMenuOpen(false);
|
|
}}
|
|
>
|
|
<span className="font-mono text-xs">{opt.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<FileAwareSubmitButton
|
|
status={chatStatus}
|
|
onStop={onStop}
|
|
disabled={disabled}
|
|
inputValue={inputValue}
|
|
hasBadge={!!badge}
|
|
/>
|
|
</div>
|
|
</PromptInputFooter>
|
|
</PromptInput>
|
|
</div>
|
|
</div>
|
|
|
|
{/* FolderPicker dialog */}
|
|
<FolderPicker
|
|
open={folderPickerOpen}
|
|
onOpenChange={setFolderPickerOpen}
|
|
onSelect={(dir) => {
|
|
onWorkingDirectoryChange?.(dir);
|
|
}}
|
|
initialPath={workingDirectory || undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|