Files
boocode/apps/web/src/components/ChatInput.tsx
indifferentketchup e09c67d65c tab-close + chat archive/delete + landing-card buttons + 1000px content cap
Feature 1 — Tab close menu (pure local pane state, no API):
- ChatTabBar context menu: Rename / sep / Close / Close others / Close to right / Close all
- Workspace bulk-tab primitives: closeOtherTabs, closeTabsToRight, closeAllTabs (manipulate panes[].chatIds, no fetch)
- Drop in-bar Delete; landing card's name-typed Delete is the canonical destructive path

Feature 2 — Chat archive + delete:
- chats.status vocabulary aligned with projects ('open' | 'archived'); DROP old inline CHECK, UPDATE 'closed' → 'archived', ADD new named chats_status_chk
- POST /api/chats/:id/archive (204) + POST /api/chats/:id/unarchive (200) + GET /api/sessions/:id/chats?status=archived; DELETE publishes chat_deleted; PATCH simplified to name-only
- 3 new WS frames: chat_archived, chat_unarchived, chat_deleted (renamed from chat_closed)
- Same dedup discipline: server-only publish, no local sessionEvents.emit in client
- SessionLandingPage: right-click ContextMenu (Open / Rename / Archive / sep / Delete-destructive), inline rename, archive confirm dialog, delete dialog with name-typed Input gated until typed text === chat.name, Archived chats collapsible section with Restore
- Card-level Archive + Delete icon buttons reusing the same dialog state setters; stopPropagation on both so card click still opens the chat; archived cards keep only Restore

UX — chat content width cap:
- ChatPane content (MessageList, queue chips, stop button, ChatInput) wrapped in inner max-w-[1000px] mx-auto w-full so messages center; outer border-t / scroll containers stay full-width so pane chrome and backgrounds remain edge-to-edge
- No new deps, no media queries (narrow viewports collapse to width naturally)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 03:21:26 +00:00

273 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
import { Send } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { flattenToMessage, inferLanguage, type Attachment } from '@/lib/attachments';
import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
interface Props {
disabled?: boolean;
projectId: string;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
}
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [mentionState, setMentionState] = useState<{
open: boolean;
query: string;
atIdx: number;
anchorRect: { top: number; left: number };
} | null>(null);
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
function addAttachment(a: Attachment) {
setAttachments(prev => {
if (prev.length >= 10) {
toast.error('Max 10 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,
});
});
}, []);
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;
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 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;
// 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), []);
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionState?.open) 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;
}
if (e.key === 'Enter' && !e.shiftKey) {
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 className="border-t">
<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>
)}
<div className="px-4 py-3 flex items-end gap-2">
<Textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="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}
/>
)}
</div>
);
}