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:
2026-05-15 20:39:48 +00:00
parent 6d9515b8a5
commit c35ec65fc4
37 changed files with 3290 additions and 1012 deletions

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Copy, X, Paperclip } from 'lucide-react';
import { codeToHtml } from 'shiki';
import { sessionEvents } from '@/hooks/sessionEvents';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Props {
path: string;
content: string;
lang: string | null;
projectId: string;
onClose: () => void;
onNavigate: (path: string) => void;
}
const SHIKI_THEME = 'github-dark';
function splitShikiLines(html: string): string[] {
const match = html.match(/<code[^>]*>([\s\S]*)<\/code>/);
if (!match) return [];
const inner = match[1]!;
const lines = inner.split(/(?=<span class="line">)/);
return lines.filter(l => l.trim().length > 0);
}
function basename(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
export function FileViewerOverlay({ path, content, lang, onClose }: Props) {
const [copied, setCopied] = useState(false);
const [lineHtmls, setLineHtmls] = useState<string[] | null>(null);
const [selectedLines, setSelectedLines] = useState<Set<number>>(new Set());
const [showAttachPopover, setShowAttachPopover] = useState(false);
const draggingRef = useRef(false);
const dragStartRef = useRef<number | null>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSelectedLines(new Set());
setShowAttachPopover(false);
if (!lang) { setLineHtmls(null); return; }
let cancelled = false;
(async () => {
try {
const result = await codeToHtml(content, { lang, theme: SHIKI_THEME });
if (!cancelled) {
const lines = splitShikiLines(result);
setLineHtmls(lines.length > 0 ? lines : null);
}
} catch { if (!cancelled) setLineHtmls(null); }
})();
return () => { cancelled = true; };
}, [content, lang]);
const plainLines = content.split('\n');
const totalLines = lineHtmls ? lineHtmls.length : plainLines.length;
async function copyAll() {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch { /* ignore */ }
}
function handleLineMouseDown(lineNo: number, e: React.MouseEvent) {
if (e.shiftKey && dragStartRef.current !== null) {
const start = dragStartRef.current;
const min = Math.min(start, lineNo);
const max = Math.max(start, lineNo);
const next = new Set<number>();
for (let i = min; i <= max; i++) next.add(i);
setSelectedLines(next);
setShowAttachPopover(true);
return;
}
draggingRef.current = true;
dragStartRef.current = lineNo;
setSelectedLines(new Set([lineNo]));
setShowAttachPopover(false);
}
function handleLineMouseEnter(lineNo: number) {
if (!draggingRef.current || dragStartRef.current === null) return;
const start = dragStartRef.current;
const min = Math.min(start, lineNo);
const max = Math.max(start, lineNo);
const next = new Set<number>();
for (let i = min; i <= max; i++) next.add(i);
setSelectedLines(next);
}
const handleMouseUp = useCallback(() => {
if (draggingRef.current) {
draggingRef.current = false;
if (selectedLines.size > 0) setShowAttachPopover(true);
}
}, [selectedLines.size]);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, [handleMouseUp]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [onClose]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
function getSelectionRange(): { min: number; max: number } | null {
if (selectedLines.size === 0) return null;
let min = Infinity;
let max = -Infinity;
for (const n of selectedLines) {
if (n < min) min = n;
if (n > max) max = n;
}
return { min, max };
}
function handleAttach() {
const range = getSelectionRange();
if (!range) return;
const lines = content.split('\n').slice(range.min - 1, range.max);
sessionEvents.emit({
type: 'attach_chat_file',
attachment: {
kind: 'lines',
filename: path,
language: lang,
content: lines.join('\n'),
range: [range.min, range.max],
source: 'line-select',
},
});
setSelectedLines(new Set());
setShowAttachPopover(false);
}
const range = getSelectionRange();
const attachLabel = range
? range.min === range.max
? `Attach line ${range.min} to chat`
: `Attach lines ${range.min}${range.max} to chat`
: '';
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-12 pb-12">
<div className="absolute inset-0 bg-black/40" />
<div
ref={overlayRef}
className="relative bg-background border rounded-lg shadow-xl flex flex-col w-[80vw] max-w-[1000px] max-h-[80vh] overflow-hidden"
>
<div className="flex items-center gap-2 px-4 py-2 border-b shrink-0">
<span className="text-sm font-medium truncate flex-1" title={path}>
{basename(path)}
</span>
<span className="text-xs text-muted-foreground truncate">{path}</span>
<button
type="button"
onClick={() => void copyAll()}
className="flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-muted"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Shiki-highlighted code lines are generated from source code files, not user content */}
<div className="flex-1 overflow-auto text-sm font-mono select-none">
{Array.from({ length: totalLines }, (_, i) => {
const lineNo = i + 1;
const isSelected = selectedLines.has(lineNo);
return (
<div
key={lineNo}
className={cn('flex', isSelected && 'bg-blue-500/10')}
onMouseDown={(e) => handleLineMouseDown(lineNo, e)}
onMouseEnter={() => handleLineMouseEnter(lineNo)}
>
<div
className="shrink-0 w-[3.5ch] text-right pr-2 text-xs text-muted-foreground select-none cursor-pointer hover:text-foreground"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{lineNo}
</div>
{lineHtmls ? (
<div
className="flex-1 min-w-0 text-xs leading-relaxed [&>.line]:!bg-transparent"
dangerouslySetInnerHTML={{ __html: lineHtmls[i] ?? '' }}
/>
) : (
<span className="flex-1 min-w-0 text-xs leading-relaxed whitespace-pre">
{plainLines[i] ?? ''}
</span>
)}
</div>
);
})}
</div>
{showAttachPopover && range && (
<div className="sticky bottom-0 border-t bg-background px-4 py-2 flex items-center gap-2">
<Paperclip size={14} className="text-muted-foreground" />
<span className="text-xs flex-1">{attachLabel}</span>
<Button size="sm" onClick={handleAttach}>
Attach
</Button>
<Button size="sm" variant="ghost" onClick={() => { setSelectedLines(new Set()); setShowAttachPopover(false); }}>
Cancel
</Button>
</div>
)}
</div>
</div>
);
}