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:
241
apps/web/src/components/FileViewerOverlay.tsx
Normal file
241
apps/web/src/components/FileViewerOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user