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:
145
apps/web/src/components/FileMentionPopover.tsx
Normal file
145
apps/web/src/components/FileMentionPopover.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
files: string[];
|
||||
anchorRect: { top: number; left: number };
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function filterAndRank(files: string[], query: string): string[] {
|
||||
const q = query.toLowerCase();
|
||||
if (!q) {
|
||||
return files.slice(0, 20);
|
||||
}
|
||||
|
||||
const filenameMatches: string[] = [];
|
||||
const pathOnlyMatches: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const lower = file.toLowerCase();
|
||||
if (!lower.includes(q)) continue;
|
||||
const basename = file.split('/').pop() ?? file;
|
||||
if (basename.toLowerCase().includes(q)) {
|
||||
filenameMatches.push(file);
|
||||
} else {
|
||||
pathOnlyMatches.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
filenameMatches.sort((a, b) => a.localeCompare(b));
|
||||
pathOnlyMatches.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
return [...filenameMatches, ...pathOnlyMatches].slice(0, 20);
|
||||
}
|
||||
|
||||
export function FileMentionPopover({
|
||||
query,
|
||||
files,
|
||||
anchorRect,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterAndRank(files, query), [files, query]);
|
||||
|
||||
// Reset highlight when query changes
|
||||
useEffect(() => {
|
||||
setHighlightIndex(0);
|
||||
}, [query]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex(prev =>
|
||||
prev < filtered.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filtered.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filtered.length > 0) {
|
||||
onSelect(filtered[highlightIndex] ?? filtered[0]!);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
|
||||
// Click outside to close
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
const el = popoverRef.current?.querySelector('[data-highlighted="true"]');
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [highlightIndex]);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[260px] p-2"
|
||||
style={{ top: anchorRect.top, left: anchorRect.left }}
|
||||
>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1">
|
||||
No matching files
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[260px] max-h-[240px] overflow-y-auto"
|
||||
style={{ top: anchorRect.top, left: anchorRect.left }}
|
||||
>
|
||||
{filtered.map((file, i) => (
|
||||
<button
|
||||
key={file}
|
||||
type="button"
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left text-xs font-mono px-2 py-1.5 cursor-pointer',
|
||||
i === highlightIndex && 'bg-muted'
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(file);
|
||||
}}
|
||||
>
|
||||
{file}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user