Files
boocode/apps/web/src/components/FileMentionPopover.tsx
indifferentketchup c35ec65fc4 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>
2026-05-15 20:39:48 +00:00

146 lines
3.9 KiB
TypeScript

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>
);
}