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>
242 lines
8.1 KiB
TypeScript
242 lines
8.1 KiB
TypeScript
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>
|
||
);
|
||
}
|