batch4: chats-in-sessions, force-send, /compact, right-rail file browser
Session 1:N Chat data model with backfill. Workspace switches to client-side multi-tab pane management. Right-rail file browser with float-over viewer and click-drag line selection replaces FileBrowserPane. Adds /compact streaming summarizer (respects compact markers in context builder), force-send (cancels in-flight, persists partial as 'cancelled', awaits cancellation completion via deferred Promise + 5s timeout), message queue, stop generation, chat auto-rename, session archive/unarchive with Closed Sessions section on repo landing page. CHECK constraints on sessions.status, messages.role, messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES / MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the api.panes.* client block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,73 @@
|
||||
import { useState, type KeyboardEvent } from 'react';
|
||||
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, onSend }: Props) {
|
||||
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 || disabled || busy) return;
|
||||
if (!text && attachments.length === 0) return;
|
||||
if (disabled || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSend(text);
|
||||
const body = flattenToMessage(attachments, text);
|
||||
await onSend(body);
|
||||
setValue('');
|
||||
setAttachments([]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to send');
|
||||
} finally {
|
||||
@@ -27,32 +75,196 @@ export function ChatInput({ disabled, onSend }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
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 ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
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 px-4 py-3 flex items-end gap-2">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask about this project. Cmd/Ctrl+Enter to send."
|
||||
disabled={disabled || busy}
|
||||
rows={3}
|
||||
className="resize-none min-h-[68px] max-h-[240px]"
|
||||
<div className="border-t">
|
||||
{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>
|
||||
<AttachmentPreviewModal
|
||||
attachment={previewAttachment}
|
||||
onClose={() => setPreviewAttachment(null)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={disabled || busy || !value.trim()}
|
||||
size="icon-lg"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
{mentionState?.open && (
|
||||
<FileMentionPopover
|
||||
query={mentionState.query}
|
||||
files={fileIndex ?? []}
|
||||
anchorRect={mentionState.anchorRect}
|
||||
onSelect={handleMentionSelect}
|
||||
onClose={closeMention}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user