feat: composer attach-file button + slash-commands chip (icon-only on mobile)

Move the slash-commands menu out of the full-width AgentCommandsHint disclosure into a compact chip in the composer's bottom controls row, and add an attach-file button that reuses the existing drag-drop pipeline (5MB/binary gate, 10-attachment cap, chips + preview). On mobile both collapse to icon-only (count hidden). Shared ChatInput, so it applies to both BooChat and BooCoder; typed-/ autocomplete is unchanged. Removes the now-unused AgentCommandsHint component.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:26:27 +00:00
parent ce621bc003
commit 35dba828e1
2 changed files with 65 additions and 55 deletions

View File

@@ -1,49 +0,0 @@
import { ChevronDown } from 'lucide-react';
import { useState } from 'react';
import type { AgentCommand } from '@/api/types';
import { cn } from '@/lib/utils';
interface Props {
commands: AgentCommand[];
}
export function AgentCommandsHint({ commands }: Props) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
if (commands.length === 0) return null;
return (
<div className="mx-2 mb-1 rounded-md border border-border/60 bg-muted/30 text-xs">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-2 py-1.5 text-muted-foreground hover:text-foreground max-md:min-h-[44px]"
>
<span>Slash commands ({commands.length})</span>
<ChevronDown className={cn('size-3.5 transition-transform', open && 'rotate-180')} />
</button>
{open && (
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
{commands.map((cmd) => (
<li
key={cmd.name}
className="cursor-pointer"
onClick={() => setExpanded((v) => v === cmd.name ? null : cmd.name)}
>
<span className="font-mono text-primary/80">/{cmd.name}</span>
{cmd.description && (
<span className={cn(
'ml-1.5 text-muted-foreground font-sans',
expanded === cmd.name ? '' : 'line-clamp-2',
)}>
{cmd.description}
</span>
)}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Globe, ListPlus, Send, Square } from 'lucide-react'; import { Globe, ListPlus, Paperclip, Send, Square, SquareSlash } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -16,7 +16,6 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover'; import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay'; import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker'; import { AgentPicker } from '@/components/AgentPicker';
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { ContextBar } from '@/components/ContextBar'; import { ContextBar } from '@/components/ContextBar';
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker'; import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
@@ -117,6 +116,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
); );
const [fileIndex, setFileIndex] = useState<string[] | null>(null); const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
// Attach-file button → hidden native picker (same File→Attachment path as drop).
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Slash-commands chip → click-to-open command menu, anchored to the chip.
const cmdChipRef = useRef<HTMLButtonElement | null>(null);
const [cmdMenuOpen, setCmdMenuOpen] = useState(false);
function addAttachment(a: Attachment) { function addAttachment(a: Attachment) {
setAttachments(prev => { setAttachments(prev => {
@@ -174,6 +178,23 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
setAttachments(prev => prev.filter(a => a.id !== id)); setAttachments(prev => prev.filter(a => a.id !== id));
} }
// Attach-file button: funnel picked files through the same size/binary gate +
// chip pipeline as drag-drop. Reset value so re-picking the same file fires.
async function onPickFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = '';
if (files.length === 0) return;
let remaining = MAX_ATTACHMENTS - attachments.length;
for (const file of files) {
if (remaining <= 0) {
toast.error(`Attachment limit reached (${MAX_ATTACHMENTS}).`);
break;
}
await processDroppedFile(file);
remaining -= 1;
}
}
async function submit() { async function submit() {
const text = value.trim(); const text = value.trim();
if (!text && attachments.length === 0) return; if (!text && attachments.length === 0) return;
@@ -576,9 +597,6 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
))} ))}
</div> </div>
)} )}
{slashItems.length > 0 && (
<AgentCommandsHint commands={slashItems} />
)}
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1 {/* 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 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 picker rather than as a separate header above it. The row renders
@@ -623,8 +641,34 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
rows={3} rows={3}
className="resize-none min-h-[56px] max-h-[240px] border-0 bg-transparent px-3 pt-2.5 shadow-none focus-visible:ring-0 dark:bg-transparent" className="resize-none min-h-[56px] max-h-[240px] border-0 bg-transparent px-3 pt-2.5 shadow-none focus-visible:ring-0 dark:bg-transparent"
/> />
{/* bottom controls row: Web toggle on the left, Send/Stop on the right */} {/* bottom controls row: attach + slash chip + Web on the left, Send/Stop on the right */}
<div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5"> <div className="flex items-center gap-1.5 px-2 pb-2 pt-0.5">
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={onPickFiles} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || busy || attachments.length >= MAX_ATTACHMENTS}
aria-label="Attach file"
title="Attach file"
className="inline-flex items-center justify-center rounded-full border border-border px-2.5 py-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50 max-md:min-h-[36px] max-md:min-w-[36px]"
>
<Paperclip className="size-3.5" />
</button>
{slashItems.length > 0 && (
<button
ref={cmdChipRef}
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={() => setCmdMenuOpen((v) => !v)}
aria-expanded={cmdMenuOpen}
aria-label="Slash commands"
title="Slash commands"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
>
<SquareSlash className="size-3.5" />
<span className="max-md:hidden">{slashItems.length}</span>
</button>
)}
{sessionId && ( {sessionId && (
<button <button
type="button" type="button"
@@ -710,6 +754,21 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'} emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
/> />
)} )}
{/* Slash-commands chip menu (click-opened); anchored to the chip. */}
{cmdMenuOpen && slashItems.length > 0 && (
<SlashCommandPicker
query=""
items={slashItems}
groups={slashGroups}
inputRef={cmdChipRef}
onSelect={(name) => {
setCmdMenuOpen(false);
handleSlashSelect(name);
}}
onClose={() => setCmdMenuOpen(false)}
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
/>
)}
</div> </div>
); );
} }