Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
691 lines
25 KiB
TypeScript
691 lines
25 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||
import { Check, Plus, Send } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import {
|
||
flattenToMessage,
|
||
inferLanguage,
|
||
looksBinary,
|
||
MAX_FILE_SIZE_BYTES,
|
||
PASTE_INLINE_MAX_LINES,
|
||
type Attachment,
|
||
} from '@/lib/attachments';
|
||
import { AttachmentChip } from '@/components/AttachmentChip';
|
||
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||
import { DropOverlay } from '@/components/DropOverlay';
|
||
import { AgentPicker } from '@/components/AgentPicker';
|
||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||
import { ContextBar } from '@/components/ContextBar';
|
||
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||
import { api } from '@/api/client';
|
||
import type { Message } from '@/api/types';
|
||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||
import { chatInputsRegistry, sendToChat } from '@/lib/events';
|
||
import { useSkills } from '@/hooks/useSkills';
|
||
import { useViewport } from '@/hooks/useViewport';
|
||
|
||
const MAX_ATTACHMENTS = 10;
|
||
|
||
interface Props {
|
||
disabled?: boolean;
|
||
projectId: string;
|
||
// Batch 9: optional so callers that pre-date the agent picker still compile.
|
||
// When omitted, the toolbar row is hidden entirely.
|
||
agentId?: string | null;
|
||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||
// v1.9: when sessionId + webSearchEnabled are both provided, the + menu
|
||
// renders next to the AgentPicker with a single "Web search" toggle item.
|
||
// The check reflects the *stored* session value (not the effective one):
|
||
// null counts as unchecked. Clicking PATCHes session.web_search_enabled
|
||
// with the inverted boolean (null → true, true → false, false → true).
|
||
sessionId?: string;
|
||
webSearchEnabled?: boolean | null;
|
||
onSend: (content: string) => void | Promise<void>;
|
||
onForceSend?: (content: string) => void | Promise<void>;
|
||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||
// ChatInput calls this with the skill name + the post-name args (possibly
|
||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||
// disables slash-command dispatch (input is sent as literal text).
|
||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||
// v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
|
||
// provided (e.g. CoderPane passing [agent commands, skills]), these labeled
|
||
// groups are shown instead of the BooChat skills. Invocation routing still
|
||
// uses the skills lookup — names not in skills (opencode's /help etc.) fall
|
||
// through and are sent to the agent as literal text. Omitted → BooChat skills
|
||
// (flat, unchanged — parity).
|
||
slashGroups?: SlashCommandGroup[];
|
||
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||
// registers in chatInputsRegistry so the terminal floating menu can list
|
||
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||
// an event appends the text to the current draft (with a newline separator
|
||
// when non-empty) and focuses — no auto-send.
|
||
chatId?: string;
|
||
chatLabel?: string;
|
||
// v1.11.5: context-bar inputs. messages drives the latest-pair walk;
|
||
// modelContextLimit is the zero-state fallback (and powers the
|
||
// auto-compaction-threshold tooltip when no assistant message has run
|
||
// yet). Both are optional so older call sites still compile.
|
||
messages?: Message[];
|
||
modelContextLimit?: number | null;
|
||
}
|
||
|
||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||
const { isMobile } = useViewport();
|
||
const [value, setValue] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||
const dropRootRef = useRef<HTMLDivElement | null>(null);
|
||
const pasteCounterRef = useRef(0);
|
||
const [mentionState, setMentionState] = useState<{
|
||
open: boolean;
|
||
query: string;
|
||
atIdx: number;
|
||
anchorRect: { top: number; left: number };
|
||
} | null>(null);
|
||
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
|
||
// the input and stays open while the input is `/<word>` with no whitespace.
|
||
// Disabled entirely when the caller doesn't pass onSlashCommand.
|
||
// SlashCommandPicker reads the live textarea rect via inputRef (textareaRef below)
|
||
// so it can recompute on visualViewport changes (iOS keyboard open/close).
|
||
const [slashState, setSlashState] = useState<{
|
||
query: string;
|
||
} | null>(null);
|
||
const { skills } = useSkills();
|
||
const skillsLookup = useMemo(() => {
|
||
const m = new Map<string, true>();
|
||
for (const s of skills) m.set(s.name, true);
|
||
return m;
|
||
}, [skills]);
|
||
// Flat display source for the hint (and the picker's no-groups fallback):
|
||
// caller-provided groups flattened, else the BooChat skills.
|
||
const slashItems = useMemo(
|
||
() =>
|
||
slashGroups
|
||
? slashGroups.flatMap((g) => g.items)
|
||
: skills.map((s) => ({ name: s.name, description: s.description })),
|
||
[slashGroups, skills],
|
||
);
|
||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
|
||
function addAttachment(a: Attachment) {
|
||
setAttachments(prev => {
|
||
if (prev.length >= MAX_ATTACHMENTS) {
|
||
toast.error(`Max ${MAX_ATTACHMENTS} attachments per message`);
|
||
return prev;
|
||
}
|
||
return [...prev, a];
|
||
});
|
||
}
|
||
|
||
const addAttachmentRef = useRef(addAttachment);
|
||
addAttachmentRef.current = addAttachment;
|
||
|
||
useEffect(() => {
|
||
return sessionEvents.subscribe((event) => {
|
||
if (event.type !== 'attach_chat_file') return;
|
||
addAttachmentRef.current({
|
||
id: crypto.randomUUID(),
|
||
...event.attachment,
|
||
});
|
||
});
|
||
}, []);
|
||
|
||
// v1.10.4: register this input in the chat-input registry so the terminal
|
||
// pane's "Send to chat" menu can list it. Re-registers when chatLabel
|
||
// changes (e.g. rename) so the menu reflects the current name.
|
||
useEffect(() => {
|
||
if (!chatId) return;
|
||
return chatInputsRegistry.register(chatId, chatLabel ?? 'Chat', () => {
|
||
textareaRef.current?.focus();
|
||
});
|
||
}, [chatId, chatLabel]);
|
||
|
||
// v1.10.4: subscribe to send_to_chat events scoped by chatId. Appends the
|
||
// payload text to the current draft (with a newline separator if the
|
||
// draft is non-empty) and focuses the textarea. Does NOT auto-submit.
|
||
useEffect(() => {
|
||
if (!chatId) return;
|
||
return sendToChat.subscribe(({ chat_id, text }) => {
|
||
if (chat_id !== chatId) return;
|
||
setValue((prev) => (prev.length === 0 ? text : `${prev}\n${text}`));
|
||
requestAnimationFrame(() => {
|
||
const ta = textareaRef.current;
|
||
if (!ta) return;
|
||
ta.focus();
|
||
// Put caret at end so the user can keep typing immediately.
|
||
const end = ta.value.length;
|
||
ta.selectionStart = ta.selectionEnd = end;
|
||
});
|
||
});
|
||
}, [chatId]);
|
||
|
||
function removeAttachment(id: string) {
|
||
setAttachments(prev => prev.filter(a => a.id !== id));
|
||
}
|
||
|
||
async function submit() {
|
||
const text = value.trim();
|
||
if (!text && attachments.length === 0) return;
|
||
if (disabled || busy) return;
|
||
|
||
// Batch 9.6: slash-command dispatch. Only when no attachments and the
|
||
// input parses to a known skill. Falls through to onSend for unknown
|
||
// slash names (literal text) or when slash dispatch isn't wired.
|
||
if (onSlashCommand && attachments.length === 0 && text.startsWith('/')) {
|
||
const parsed = parseSlashInput(text);
|
||
if (parsed && skillsLookup.has(parsed.cmdName)) {
|
||
setBusy(true);
|
||
try {
|
||
await onSlashCommand(parsed.cmdName, parsed.args);
|
||
setValue('');
|
||
setAttachments([]);
|
||
setSlashState(null);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
return;
|
||
}
|
||
// Unknown skill name — fall through and send as literal text.
|
||
}
|
||
|
||
setBusy(true);
|
||
try {
|
||
const body = flattenToMessage(attachments, text);
|
||
await onSend(body);
|
||
setValue('');
|
||
setAttachments([]);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'failed to send');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function handleSlashSelect(skillName: string) {
|
||
const next = `/${skillName} `;
|
||
setValue(next);
|
||
setSlashState(null);
|
||
requestAnimationFrame(() => {
|
||
const ta = textareaRef.current;
|
||
if (ta) {
|
||
ta.selectionStart = ta.selectionEnd = next.length;
|
||
ta.focus();
|
||
}
|
||
});
|
||
}
|
||
|
||
function getCaretCoords(textarea: HTMLTextAreaElement): { top: number; left: number } {
|
||
const mirror = document.createElement('div');
|
||
const style = window.getComputedStyle(textarea);
|
||
|
||
const properties = [
|
||
'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
|
||
'letterSpacing', 'lineHeight', 'textTransform', 'wordSpacing',
|
||
'textIndent', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
||
'boxSizing', 'whiteSpace', 'overflowWrap',
|
||
] as const;
|
||
|
||
mirror.style.position = 'absolute';
|
||
mirror.style.visibility = 'hidden';
|
||
mirror.style.overflow = 'hidden';
|
||
mirror.style.width = style.width;
|
||
for (const prop of properties) {
|
||
mirror.style[prop] = style[prop];
|
||
}
|
||
mirror.style.whiteSpace = 'pre-wrap';
|
||
mirror.style.overflowWrap = 'break-word';
|
||
|
||
const textBefore = textarea.value.slice(0, textarea.selectionStart);
|
||
mirror.textContent = textBefore;
|
||
|
||
const span = document.createElement('span');
|
||
span.textContent = ''; // zero-width space
|
||
mirror.appendChild(span);
|
||
|
||
document.body.appendChild(mirror);
|
||
|
||
const taRect = textarea.getBoundingClientRect();
|
||
const spanRect = span.getBoundingClientRect();
|
||
const mirrorRect = mirror.getBoundingClientRect();
|
||
|
||
const top = taRect.top + (spanRect.top - mirrorRect.top) - textarea.scrollTop + span.offsetHeight;
|
||
const left = taRect.left + (spanRect.left - mirrorRect.left);
|
||
|
||
document.body.removeChild(mirror);
|
||
|
||
return { top, left };
|
||
}
|
||
|
||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||
const newValue = e.target.value;
|
||
setValue(newValue);
|
||
|
||
const ta = e.target;
|
||
const pos = ta.selectionStart;
|
||
|
||
// Batch 9.6: slash-command trigger. Active while the input is a single
|
||
// slash-prefixed token with no whitespace (i.e. user is still typing the
|
||
// skill name). Hand off to args mode the moment a space appears or the
|
||
// slash leaves position 0.
|
||
if (onSlashCommand && isSlashCommandToken(newValue)) {
|
||
const query = slashQuery(newValue);
|
||
if (!slashState) {
|
||
setSlashState({ query });
|
||
} else if (slashState.query !== query) {
|
||
setSlashState({ query });
|
||
}
|
||
if (mentionState?.open) setMentionState(null);
|
||
return;
|
||
}
|
||
if (slashState) setSlashState(null);
|
||
|
||
// Check for @ trigger
|
||
if (pos > 0 && newValue[pos - 1] === '@') {
|
||
const charBefore = pos >= 2 ? newValue[pos - 2] : null;
|
||
if (charBefore === null || charBefore === ' ' || charBefore === '\n') {
|
||
const coords = getCaretCoords(ta);
|
||
setMentionState({ open: true, query: '', atIdx: pos - 1, anchorRect: coords });
|
||
if (!fileIndex) {
|
||
api.projects.files(projectId).then(r => setFileIndex(r.files)).catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Update query if popover is open — use stored atIdx
|
||
if (mentionState?.open) {
|
||
const { atIdx } = mentionState;
|
||
if (atIdx < pos && newValue[atIdx] === '@') {
|
||
const query = newValue.slice(atIdx + 1, pos);
|
||
setMentionState(prev => prev ? { ...prev, query } : null);
|
||
} else {
|
||
setMentionState(null);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleMentionSelect(path: string) {
|
||
const atIdx = mentionState?.atIdx ?? -1;
|
||
const ta = textareaRef.current;
|
||
const caretPos = ta?.selectionStart ?? value.length;
|
||
setMentionState(null);
|
||
|
||
try {
|
||
const result = await api.projects.viewFile(projectId, path);
|
||
if (atIdx >= 0) {
|
||
const cleaned = value.slice(0, atIdx) + value.slice(caretPos);
|
||
setValue(cleaned);
|
||
if (ta) {
|
||
requestAnimationFrame(() => {
|
||
ta.selectionStart = ta.selectionEnd = atIdx;
|
||
ta.focus();
|
||
});
|
||
}
|
||
}
|
||
addAttachment({
|
||
id: crypto.randomUUID(),
|
||
kind: 'file',
|
||
filename: path,
|
||
language: inferLanguage(path),
|
||
content: result.content,
|
||
source: '@',
|
||
});
|
||
} catch {
|
||
toast.error('Failed to load file');
|
||
}
|
||
}
|
||
|
||
const closeMention = useCallback(() => setMentionState(null), []);
|
||
|
||
// ---- Drag & drop (F1 + F3 + F4) ----------------------------------------
|
||
// The drop zone is the outer ChatInput container (ref'd as dropRootRef).
|
||
// onDragLeave only clears the highlight when the cursor leaves the
|
||
// container, not when it crosses into a child element.
|
||
|
||
async function processDroppedFile(file: File) {
|
||
// Size gate
|
||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||
const mb = (file.size / (1024 * 1024)).toFixed(1);
|
||
toast.error(`File ${file.name} is too large (${mb} MB). Limit is 5 MB.`);
|
||
return;
|
||
}
|
||
// Read once as ArrayBuffer so we can do byte-level binary detection
|
||
// before deciding whether to decode as text.
|
||
let buf: ArrayBuffer;
|
||
try {
|
||
buf = await file.arrayBuffer();
|
||
} catch (err) {
|
||
toast.error(`Failed to read ${file.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||
return;
|
||
}
|
||
if (looksBinary(buf)) {
|
||
toast.error(`${file.name} appears to be binary.`);
|
||
return;
|
||
}
|
||
const text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||
addAttachment({
|
||
id: crypto.randomUUID(),
|
||
kind: 'file',
|
||
filename: file.name,
|
||
language: inferLanguage(file.name),
|
||
content: text,
|
||
source: 'drop',
|
||
});
|
||
}
|
||
|
||
function isFolderItem(item: DataTransferItem | undefined): boolean {
|
||
if (!item) return false;
|
||
// webkitGetAsEntry is non-standard but supported in Chromium + Safari.
|
||
// If unavailable, we conservatively treat the entry as a file.
|
||
const entry =
|
||
typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
|
||
if (entry && entry.isDirectory) return true;
|
||
// Heuristic fallback: folders dragged from Finder have type === '' and
|
||
// a 0-byte File. The empty-type alone isn't reliable for files (some
|
||
// plaintext drops also lack a type), so we only flag when the entry
|
||
// explicitly says directory.
|
||
return false;
|
||
}
|
||
|
||
async function handleDroppedItems(dt: DataTransfer) {
|
||
// Snapshot items first because reading files inside the loop can
|
||
// detach the DataTransfer between awaits.
|
||
const itemsArray: { file: File | null; isFolder: boolean }[] = [];
|
||
if (dt.items && dt.items.length > 0) {
|
||
for (let i = 0; i < dt.items.length; i++) {
|
||
const it = dt.items[i];
|
||
if (!it || it.kind !== 'file') continue;
|
||
const folder = isFolderItem(it);
|
||
const file = folder ? null : it.getAsFile();
|
||
itemsArray.push({ file, isFolder: folder });
|
||
}
|
||
} else {
|
||
for (let i = 0; i < dt.files.length; i++) {
|
||
const f = dt.files[i];
|
||
if (f) itemsArray.push({ file: f, isFolder: false });
|
||
}
|
||
}
|
||
|
||
let remainingSlots = MAX_ATTACHMENTS - attachments.length;
|
||
let folderRejected = false;
|
||
for (const { file, isFolder } of itemsArray) {
|
||
if (isFolder) {
|
||
if (!folderRejected) {
|
||
toast.error('Folders are not supported');
|
||
folderRejected = true;
|
||
}
|
||
continue;
|
||
}
|
||
if (!file) continue;
|
||
if (remainingSlots <= 0) {
|
||
toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
|
||
return;
|
||
}
|
||
await processDroppedFile(file);
|
||
remainingSlots -= 1;
|
||
}
|
||
}
|
||
|
||
function onDragEnter(e: DragEvent<HTMLDivElement>) {
|
||
if (disabled || busy) return;
|
||
e.preventDefault();
|
||
setIsDraggingOver(true);
|
||
}
|
||
|
||
function onDragOver(e: DragEvent<HTMLDivElement>) {
|
||
if (disabled || busy) return;
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
|
||
function onDragLeave(e: DragEvent<HTMLDivElement>) {
|
||
// Only clear when the cursor actually leaves the root container.
|
||
// relatedTarget is the element being entered; if it's inside the root,
|
||
// ignore — we're just crossing into a child.
|
||
const root = dropRootRef.current;
|
||
if (!root) return;
|
||
const related = e.relatedTarget as Node | null;
|
||
if (related && root.contains(related)) return;
|
||
setIsDraggingOver(false);
|
||
}
|
||
|
||
function onDrop(e: DragEvent<HTMLDivElement>) {
|
||
e.preventDefault();
|
||
setIsDraggingOver(false);
|
||
if (disabled || busy) return;
|
||
void handleDroppedItems(e.dataTransfer);
|
||
}
|
||
// ---- end Drag & drop -----------------------------------------------------
|
||
|
||
// ---- Paste-as-attachment (F2) -------------------------------------------
|
||
// Pasting >PASTE_INLINE_MAX_LINES lines of text becomes a chip rather than
|
||
// inline content. Image pastes are rejected with a toast. If both text and
|
||
// image are present (e.g. screenshot tool that sets both), prefer text.
|
||
|
||
function onPaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
|
||
const cd = e.clipboardData;
|
||
if (!cd) return;
|
||
const text = cd.getData('text/plain');
|
||
const hasImage = Array.from(cd.items ?? []).some((it) =>
|
||
it.type.startsWith('image/'),
|
||
);
|
||
if (text) {
|
||
const lineCount = text.split('\n').length;
|
||
if (lineCount > PASTE_INLINE_MAX_LINES) {
|
||
e.preventDefault();
|
||
pasteCounterRef.current += 1;
|
||
addAttachment({
|
||
id: crypto.randomUUID(),
|
||
kind: 'paste',
|
||
filename: `pasted-${pasteCounterRef.current}.txt`,
|
||
language: 'plaintext',
|
||
content: text,
|
||
source: 'paste',
|
||
});
|
||
}
|
||
// <= threshold: let default paste insert inline.
|
||
return;
|
||
}
|
||
if (hasImage) {
|
||
e.preventDefault();
|
||
toast.error('Image paste is not supported. Drop a file or paste text.');
|
||
}
|
||
}
|
||
// ---- end Paste-as-attachment --------------------------------------------
|
||
|
||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||
if (mentionState?.open) return;
|
||
// SlashCommandPicker owns Arrow/Enter/Tab/Esc via a document listener; let
|
||
// it consume them so the textarea doesn't also submit on Enter.
|
||
if (slashState) return;
|
||
// IME safety: never act on Enter while an IME composition is in flight
|
||
// (CJK input methods commit composition via Enter). Without this, the
|
||
// first Enter of a Japanese/Chinese/Korean composition would submit
|
||
// instead of finalizing the candidate.
|
||
if (e.nativeEvent.isComposing) return;
|
||
if (e.key === 'Enter' && e.shiftKey && (e.metaKey || e.ctrlKey) && onForceSend) {
|
||
e.preventDefault();
|
||
void forceSubmit();
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||
e.preventDefault();
|
||
void submit();
|
||
return;
|
||
}
|
||
// Bare Enter: sends on desktop, inserts a newline on mobile (per spec —
|
||
// send is via the dedicated button on touch devices).
|
||
if (e.key === 'Enter' && !e.shiftKey && !isMobile) {
|
||
e.preventDefault();
|
||
void submit();
|
||
}
|
||
}
|
||
|
||
async function forceSubmit() {
|
||
const text = value.trim();
|
||
if (!text || !onForceSend) return;
|
||
if (busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const body = flattenToMessage(attachments, text);
|
||
await onForceSend(body);
|
||
setValue('');
|
||
setAttachments([]);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'force send failed');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={dropRootRef}
|
||
className="border-t relative"
|
||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||
onDragEnter={onDragEnter}
|
||
onDragOver={onDragOver}
|
||
onDragLeave={onDragLeave}
|
||
onDrop={onDrop}
|
||
>
|
||
<DropOverlay visible={isDraggingOver} />
|
||
<div className="max-w-[1000px] mx-auto w-full">
|
||
{attachments.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 px-4 pt-3">
|
||
{attachments.map(a => (
|
||
<AttachmentChip
|
||
key={a.id}
|
||
attachment={a}
|
||
onRemove={removeAttachment}
|
||
onPreview={setPreviewAttachment}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{slashItems.length > 0 && (
|
||
<AgentCommandsHint commands={slashItems} />
|
||
)}
|
||
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
||
inlines ContextBar in the same row so the bar lives next to the
|
||
picker rather than as a separate header above it. The row renders
|
||
when ANY of {picker, quick-toggle, ContextBar} is wanted. */}
|
||
{(onAgentChange || sessionId || messages !== undefined) && (
|
||
<div className="px-4 pt-2 flex items-center gap-1.5">
|
||
{onAgentChange && (
|
||
<AgentPicker
|
||
projectId={projectId}
|
||
value={agentId ?? null}
|
||
onChange={onAgentChange}
|
||
/>
|
||
)}
|
||
{sessionId && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
type="button"
|
||
aria-label="Quick toggles"
|
||
title="Quick toggles"
|
||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||
>
|
||
<Plus className="size-3.5" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="start">
|
||
<DropdownMenuItem
|
||
onSelect={async () => {
|
||
// v1.9: tri-state collapses to two on the wire when toggled
|
||
// here. null (inherit) treated as off; click flips to true.
|
||
// To restore "inherit" the user opens SettingsPane.
|
||
const next = webSearchEnabled === true ? false : true;
|
||
try {
|
||
await api.sessions.update(sessionId, { web_search_enabled: next });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : 'failed to toggle web search');
|
||
}
|
||
}}
|
||
className="text-xs"
|
||
>
|
||
<Check className={`size-3 ${webSearchEnabled === true ? 'opacity-100' : 'opacity-0'}`} />
|
||
Enable web search and fetch
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
{/* v1.11.5.1: ContextBar fills the remaining horizontal space.
|
||
`flex-1 min-w-0` is set inside the component. Mounts only when
|
||
the caller passes `messages` so older call sites (without the
|
||
prop) keep their original layout. */}
|
||
{messages !== undefined && (
|
||
<ContextBar messages={messages} modelContextLimit={modelContextLimit} />
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="px-4 py-3 flex items-end gap-2">
|
||
<Textarea
|
||
ref={textareaRef}
|
||
value={value}
|
||
onChange={handleChange}
|
||
onKeyDown={onKeyDown}
|
||
onPaste={onPaste}
|
||
placeholder={
|
||
isMobile
|
||
? 'Ask about this project. Tap send to submit.'
|
||
: 'Ask about this project. Enter to send · Shift+Enter for newline.'
|
||
}
|
||
disabled={disabled || busy}
|
||
rows={3}
|
||
className="resize-none min-h-[68px] max-h-[240px]"
|
||
/>
|
||
<Button
|
||
onClick={() => void submit()}
|
||
disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
|
||
size="icon-lg"
|
||
aria-label="Send"
|
||
>
|
||
<Send />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<AttachmentPreviewModal
|
||
attachment={previewAttachment}
|
||
onClose={() => setPreviewAttachment(null)}
|
||
/>
|
||
{mentionState?.open && (
|
||
<FileMentionPopover
|
||
query={mentionState.query}
|
||
files={fileIndex ?? []}
|
||
anchorRect={mentionState.anchorRect}
|
||
onSelect={handleMentionSelect}
|
||
onClose={closeMention}
|
||
/>
|
||
)}
|
||
{slashState && (
|
||
<SlashCommandPicker
|
||
query={slashState.query}
|
||
items={slashItems}
|
||
groups={slashGroups}
|
||
inputRef={textareaRef}
|
||
onSelect={handleSlashSelect}
|
||
onClose={() => setSlashState(null)}
|
||
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|