batch3 T7: pane components — PaneShell, ChatPane, FileBrowserPane, PaneTab, Workspace

- PaneShell: per-pane chrome (kind label + close)
- ChatPane: extracts message+input rendering, subscribes to useSessionStream
- FileBrowserPane: tree + filter (debounced 100ms) + inline viewer via Shiki
- PaneTab: tab with kind icon + context menu (Split, Close, Close others,
  Close to right, Close all) via shadcn ContextMenu
- Workspace: tab strip + pane grid (CSS grid repeat(N,1fr)), native HTML5
  drag-to-reorder, "+" button (disabled at 5), subscribes to
  open_file_in_browser (focus existing file-browser pane or spawn one)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:46:14 +00:00
parent 2de67fe091
commit fb31e63d10
6 changed files with 1383 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
import type { DragEvent } from 'react';
import { FolderOpen, MessageSquare, X } from 'lucide-react';
import type { Pane, PaneKind } from '@/api/types';
import { cn } from '@/lib/utils';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
interface Props {
pane: Pane;
isActive: boolean;
onClick: () => void;
onClose: () => void;
onSplit: (kind: PaneKind) => void;
onCloseOthers: () => void;
onCloseToRight: () => void;
onCloseAll: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
}
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function labelFor(pane: Pane): string {
if (pane.kind === 'chat') return 'Chat';
const openFile = pane.state.open_file;
if (openFile) return basename(openFile);
return 'Files';
}
export function PaneTab({
pane,
isActive,
onClick,
onClose,
onSplit,
onCloseOthers,
onCloseToRight,
onCloseAll,
onDragStart,
onDragOver,
onDrop,
}: Props) {
const Icon = pane.kind === 'chat' ? MessageSquare : FolderOpen;
const label = labelFor(pane);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
draggable
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={onClick}
className={cn(
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none',
isActive
? 'bg-background text-foreground'
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
)}
role="tab"
aria-selected={isActive}
>
<Icon size={12} className="shrink-0" />
<span className="truncate max-w-[160px]" title={label}>
{label}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="p-0.5 hover:bg-muted rounded opacity-60 hover:opacity-100 shrink-0"
aria-label="Close tab"
>
<X size={10} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Split</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onSelect={() => onSplit('chat')}>
<MessageSquare /> Chat
</ContextMenuItem>
<ContextMenuItem onSelect={() => onSplit('file_browser')}>
<FolderOpen /> File Browser
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onClose}>Close</ContextMenuItem>
<ContextMenuItem onSelect={onCloseOthers}>Close others</ContextMenuItem>
<ContextMenuItem onSelect={onCloseToRight}>
Close to the right
</ContextMenuItem>
<ContextMenuItem onSelect={onCloseAll}>Close all</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -0,0 +1,297 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { DragEvent } from 'react';
import { Plus } from 'lucide-react';
import { usePanes } from '@/hooks/usePanes';
import { sessionEvents } from '@/hooks/sessionEvents';
import type { FileBrowserPaneState, Pane, PaneKind } from '@/api/types';
import { PaneTab } from '@/components/PaneTab';
import { PaneShell } from '@/components/panes/PaneShell';
import { ChatPane } from '@/components/panes/ChatPane';
import { FileBrowserPane } from '@/components/panes/FileBrowserPane';
import { cn } from '@/lib/utils';
interface Props {
sessionId: string;
projectId: string;
}
const MAX_PANES = 5;
function PaneSkeleton() {
return (
<div className="flex flex-col h-full">
<div className="flex items-center border-b border-border bg-muted/20 h-8" />
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Loading panes...
</div>
</div>
);
}
function PaneError({
message,
onRetry,
}: {
message: string;
onRetry: () => void | Promise<void>;
}) {
return (
<div className="flex flex-col h-full items-center justify-center gap-2 text-sm">
<span className="text-destructive">{message}</span>
<button
type="button"
onClick={() => void onRetry()}
className="text-xs underline text-muted-foreground hover:text-foreground"
>
Retry
</button>
</div>
);
}
export function Workspace({ sessionId, projectId }: Props) {
const { panes, loading, error, create, update, remove, refresh } =
usePanes(sessionId);
const [activeId, setActiveId] = useState<string | null>(null);
const draggingIdRef = useRef<string | null>(null);
// Keep latest panes in a ref so the event-bus subscription doesn't need
// to re-subscribe whenever the list changes (which would race with rapid
// updates).
const panesRef = useRef<Pane[] | null>(null);
panesRef.current = panes;
// Default active: first pane (and reset if the active one disappears)
useEffect(() => {
if (!panes || panes.length === 0) {
if (activeId !== null) setActiveId(null);
return;
}
if (!panes.some((p) => p.id === activeId)) {
setActiveId(panes[0]!.id);
}
}, [panes, activeId]);
// Subscribe to open_file_in_browser events: focus an existing file_browser
// pane (updating its open_file) or spawn one if room is available.
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type !== 'open_file_in_browser') return;
void (async () => {
const current = panesRef.current;
if (!current) return;
const fb = current.find(
(p): p is Pane & { kind: 'file_browser' } =>
p.kind === 'file_browser'
);
if (fb) {
const nextState: FileBrowserPaneState = {
...fb.state,
open_file: event.path,
};
await update(fb.id, { state: nextState });
setActiveId(fb.id);
} else if (current.length < MAX_PANES) {
const newPane = await create({ kind: 'file_browser' });
const nextState: FileBrowserPaneState = {
open_file: event.path,
filter: '',
expanded_dirs: [],
};
await update(newPane.id, { state: nextState });
setActiveId(newPane.id);
}
})();
});
}, [create, update]);
const handleClose = useCallback(
async (id: string) => {
try {
await remove(id);
} catch {
/* error surfaced via hook state */
}
},
[remove]
);
const handleSplit = useCallback(
async (afterIdx: number, kind: PaneKind) => {
const current = panesRef.current;
if (!current || current.length >= MAX_PANES) return;
try {
const created = await create({ kind, position: afterIdx + 1 });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
}
},
[create]
);
const handleCloseOthers = useCallback(
async (id: string) => {
const current = panesRef.current;
if (!current) return;
const targets = current.filter((p) => p.id !== id).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
// Stop on first failure to avoid cascading errors.
return;
}
}
},
[remove]
);
const handleCloseToRight = useCallback(
async (idx: number) => {
const current = panesRef.current;
if (!current) return;
const targets = current.slice(idx + 1).map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
},
[remove]
);
const handleCloseAll = useCallback(async () => {
const current = panesRef.current;
if (!current) return;
const targets = current.map((p) => p.id);
for (const targetId of targets) {
try {
await remove(targetId);
} catch {
return;
}
}
}, [remove]);
const handleAdd = useCallback(async () => {
const current = panesRef.current;
if (current && current.length >= MAX_PANES) return;
try {
const created = await create({ kind: 'chat' });
setActiveId(created.id);
} catch {
/* error surfaced via hook state */
}
}, [create]);
const handleDragStart = useCallback(
(id: string) => (e: DragEvent<HTMLDivElement>) => {
draggingIdRef.current = id;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
},
[]
);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}, []);
const handleDrop = useCallback(
(targetIdx: number) => async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const draggedId =
draggingIdRef.current || e.dataTransfer.getData('text/plain');
draggingIdRef.current = null;
if (!draggedId) return;
const current = panesRef.current;
if (!current) return;
const draggedIdx = current.findIndex((p) => p.id === draggedId);
if (draggedIdx < 0 || draggedIdx === targetIdx) return;
try {
await update(draggedId, { position: targetIdx });
} catch {
/* error surfaced via hook state */
}
},
[update]
);
if (loading && !panes) return <PaneSkeleton />;
if (error && !panes) return <PaneError message={error} onRetry={refresh} />;
if (!panes) return <PaneSkeleton />;
return (
<div className="flex flex-col h-full min-h-0">
<div
className="flex items-center border-b border-border bg-muted/20"
role="tablist"
>
{panes.map((pane, idx) => (
<PaneTab
key={pane.id}
pane={pane}
isActive={pane.id === activeId}
onClick={() => setActiveId(pane.id)}
onClose={() => void handleClose(pane.id)}
onSplit={(kind) => void handleSplit(idx, kind)}
onCloseOthers={() => void handleCloseOthers(pane.id)}
onCloseToRight={() => void handleCloseToRight(idx)}
onCloseAll={() => void handleCloseAll()}
onDragStart={handleDragStart(pane.id)}
onDragOver={handleDragOver}
onDrop={handleDrop(idx)}
/>
))}
<button
type="button"
onClick={() => void handleAdd()}
disabled={panes.length >= MAX_PANES}
className={cn(
'p-1.5 ml-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground',
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
)}
aria-label="Add pane"
>
<Plus size={14} />
</button>
</div>
{panes.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
No panes. Click + to add one.
</div>
) : (
<div
className="flex-1 grid min-h-0"
style={{
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
}}
>
{panes.map((pane) => (
<PaneShell
key={pane.id}
pane={pane}
onClose={() => void handleClose(pane.id)}
>
{pane.kind === 'chat' ? (
<ChatPane sessionId={sessionId} />
) : (
<FileBrowserPane
pane={pane}
projectId={projectId}
onStateChange={(state) =>
void update(pane.id, { state })
}
/>
)}
</PaneShell>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
import { MessageList } from '@/components/MessageList';
import { ChatInput } from '@/components/ChatInput';
interface Props {
sessionId: string;
}
export function ChatPane({ sessionId }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
// Surface stream errors via toast — matches Session.tsx behavior.
useEffect(() => {
if (stream.error && stream.error !== lastErrorRef.current) {
lastErrorRef.current = stream.error;
toast.error(stream.error);
}
if (!stream.error) {
lastErrorRef.current = null;
}
}, [stream.error]);
async function handleSend(content: string) {
await api.messages.send(sessionId, content);
}
const streaming = stream.messages.some((m) => m.status === 'streaming');
return (
<div className="flex flex-col h-full min-h-0">
<MessageList messages={stream.messages} sessionId={sessionId} />
<ChatInput disabled={streaming} onSend={handleSend} />
</div>
);
}

View File

@@ -0,0 +1,637 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, X } from 'lucide-react';
import { api, ApiError } from '@/api/client';
import type {
FileBrowserPaneState,
FileEntry,
Pane,
ViewFileResult,
} from '@/api/types';
import { CodeBlock } from '@/components/CodeBlock';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane & { kind: 'file_browser' };
projectId: string;
onStateChange: (state: FileBrowserPaneState) => void;
}
const LANG_BY_EXT: Record<string, string> = {
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
mjs: 'javascript',
cjs: 'javascript',
py: 'python',
go: 'go',
rs: 'rust',
rb: 'ruby',
java: 'java',
c: 'c',
h: 'c',
cpp: 'cpp',
hpp: 'cpp',
cs: 'csharp',
php: 'php',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
yaml: 'yaml',
yml: 'yaml',
json: 'json',
toml: 'toml',
md: 'markdown',
markdown: 'markdown',
sql: 'sql',
dockerfile: 'dockerfile',
html: 'html',
htm: 'html',
css: 'css',
scss: 'scss',
};
function deriveLang(filePath: string): string | undefined {
// basename
const base = filePath.split('/').pop() ?? filePath;
if (base.toLowerCase() === 'dockerfile') return 'dockerfile';
const dot = base.lastIndexOf('.');
if (dot < 0 || dot === base.length - 1) return undefined;
const ext = base.slice(dot + 1).toLowerCase();
return LANG_BY_EXT[ext];
}
function basename(path: string): string {
if (!path) return '';
const parts = path.split('/');
return parts[parts.length - 1] ?? path;
}
function joinPath(parent: string, name: string): string {
if (!parent || parent === '.' || parent === '') return name;
return `${parent}/${name}`;
}
interface TreeNodeProps {
parentPath: string; // '' for root children
entries: FileEntry[];
cache: Map<string, FileEntry[]>;
expanded: Set<string>;
openFile: string | null;
highlightedPath: string | null;
depth: number;
onToggleDir: (dirPath: string) => void;
onSelectFile: (path: string) => void;
setHighlightedPath: (p: string) => void;
}
function TreeNode({
parentPath,
entries,
cache,
expanded,
openFile,
highlightedPath,
depth,
onToggleDir,
onSelectFile,
setHighlightedPath,
}: TreeNodeProps) {
// Sort: dirs first, then files; alphabetical within each.
const sorted = useMemo(() => {
const copy = [...entries];
copy.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return copy;
}, [entries]);
return (
<ul className="list-none">
{sorted.map((entry) => {
const fullPath = joinPath(parentPath, entry.name);
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
const isActive = entry.kind === 'file' && openFile === fullPath;
const isHighlight = highlightedPath === fullPath;
return (
<li key={fullPath}>
<div
data-path={fullPath}
data-kind={entry.kind}
className={cn(
'flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
style={{ paddingLeft: 4 + depth * 12 }}
onClick={() => {
setHighlightedPath(fullPath);
if (entry.kind === 'dir') {
onToggleDir(fullPath);
} else {
onSelectFile(fullPath);
}
}}
>
{entry.kind === 'dir' ? (
<button
type="button"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
className="p-0.5 hover:bg-muted rounded shrink-0"
onClick={(e) => {
e.stopPropagation();
setHighlightedPath(fullPath);
onToggleDir(fullPath);
}}
>
{isExpanded ? (
<ChevronDown size={10} />
) : (
<ChevronRight size={10} />
)}
</button>
) : (
<span className="w-[16px] shrink-0" />
)}
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.name}</span>
</div>
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
<TreeNode
parentPath={fullPath}
entries={cache.get(fullPath) ?? []}
cache={cache}
expanded={expanded}
openFile={openFile}
highlightedPath={highlightedPath}
depth={depth + 1}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</li>
);
})}
</ul>
);
}
export function FileBrowserPane({ pane, projectId, onStateChange }: Props) {
const openFile = pane.state.open_file ?? null;
const filter = pane.state.filter ?? '';
const expandedDirs = useMemo(
() => pane.state.expanded_dirs ?? [],
[pane.state.expanded_dirs]
);
// Local filter (debounced 100ms before pushing to onStateChange)
const [filterDraft, setFilterDraft] = useState(filter);
const filterDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track previous external filter so we can sync local draft when the
// canonical state changes from outside (e.g. server snapshot, other tab).
const lastExternalFilter = useRef(filter);
useEffect(() => {
if (filter !== lastExternalFilter.current) {
lastExternalFilter.current = filter;
setFilterDraft(filter);
}
}, [filter]);
function onFilterInput(value: string) {
setFilterDraft(value);
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
filterDebounceRef.current = setTimeout(() => {
filterDebounceRef.current = null;
lastExternalFilter.current = value;
onStateChange({
...pane.state,
filter: value,
open_file: openFile,
expanded_dirs: expandedDirs,
});
}, 100);
}
useEffect(() => {
return () => {
if (filterDebounceRef.current !== null) {
clearTimeout(filterDebounceRef.current);
}
};
}, []);
// Directory cache: dirPath -> entries
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const [dirErrors, setDirErrors] = useState<Map<string, string>>(new Map());
const loadDir = useCallback(
async (dirPath: string) => {
// dirPath '' is root; server expects '.'
const apiPath = dirPath === '' ? '.' : dirPath;
setLoadingDirs((prev) => {
if (prev.has(dirPath)) return prev;
const next = new Set(prev);
next.add(dirPath);
return next;
});
try {
const result = await api.projects.listDir(projectId, apiPath);
setCache((prev) => {
const next = new Map(prev);
next.set(dirPath, result.entries);
return next;
});
setDirErrors((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Map(prev);
next.delete(dirPath);
return next;
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'failed to list directory';
setDirErrors((prev) => {
const next = new Map(prev);
next.set(dirPath, msg);
return next;
});
} finally {
setLoadingDirs((prev) => {
if (!prev.has(dirPath)) return prev;
const next = new Set(prev);
next.delete(dirPath);
return next;
});
}
},
[projectId]
);
// Load root on mount + any expanded dirs from server state.
useEffect(() => {
if (!cache.has('')) {
void loadDir('');
}
for (const dir of expandedDirs) {
if (!cache.has(dir)) {
void loadDir(dir);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
// When expandedDirs grows (e.g. user expands), ensure new dir is loaded.
useEffect(() => {
for (const dir of expandedDirs) {
if (!cache.has(dir) && !loadingDirs.has(dir)) {
void loadDir(dir);
}
}
}, [expandedDirs, cache, loadingDirs, loadDir]);
const expandedSet = useMemo(() => new Set(expandedDirs), [expandedDirs]);
function toggleDir(dirPath: string) {
let nextDirs: string[];
if (expandedSet.has(dirPath)) {
nextDirs = expandedDirs.filter((d) => d !== dirPath);
} else {
nextDirs = [...expandedDirs, dirPath];
}
onStateChange({
...pane.state,
open_file: openFile,
filter: filterDraft,
expanded_dirs: nextDirs,
});
}
function selectFile(path: string) {
onStateChange({
...pane.state,
open_file: path,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
function closeOpenFile() {
onStateChange({
...pane.state,
open_file: null,
filter: filterDraft,
expanded_dirs: expandedDirs,
});
}
// Build a flat list of all entries reachable through the loaded cache,
// for filter results and keyboard navigation.
interface FlatEntry {
path: string;
name: string;
kind: 'file' | 'dir';
}
const flattenedVisible = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
const sorted = [...entries].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const e of sorted) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir' && expandedSet.has(full)) {
walk(full);
}
}
}
walk('');
return result;
}, [cache, expandedSet]);
const flattenedAll = useMemo<FlatEntry[]>(() => {
const result: FlatEntry[] = [];
function walk(dirPath: string) {
const entries = cache.get(dirPath);
if (!entries) return;
for (const e of entries) {
const full = joinPath(dirPath, e.name);
result.push({ path: full, name: e.name, kind: e.kind });
if (e.kind === 'dir') walk(full);
}
}
walk('');
return result;
}, [cache]);
const trimmedFilter = filterDraft.trim();
const filterActive = trimmedFilter.length > 0;
const filterResults = useMemo<FlatEntry[]>(() => {
if (!filterActive) return [];
const needle = trimmedFilter.toLowerCase();
return flattenedAll.filter((e) => e.path.toLowerCase().includes(needle));
}, [filterActive, trimmedFilter, flattenedAll]);
// Keyboard navigation
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
const treeRef = useRef<HTMLDivElement | null>(null);
// Reset highlight if it falls out of the current list (e.g. when filter
// changes or dirs collapse).
useEffect(() => {
if (!highlightedPath) return;
const list = filterActive ? filterResults : flattenedVisible;
if (!list.some((e) => e.path === highlightedPath)) {
setHighlightedPath(null);
}
}, [highlightedPath, filterActive, filterResults, flattenedVisible]);
function onTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const list = filterActive ? filterResults : flattenedVisible;
if (list.length === 0) return;
const idx = highlightedPath
? list.findIndex((entry) => entry.path === highlightedPath)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(list.length - 1, idx + 1);
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const next = idx <= 0 ? 0 : idx - 1;
const target = list[next];
if (target) setHighlightedPath(target.path);
return;
}
if (e.key === 'Enter') {
if (idx < 0) return;
const target = list[idx];
if (!target) return;
e.preventDefault();
if (target.kind === 'dir') {
toggleDir(target.path);
} else {
selectFile(target.path);
}
}
}
// Viewer state
const [viewer, setViewer] = useState<{
path: string;
state: 'loading' | 'ready' | 'error';
result?: ViewFileResult;
error?: string;
} | null>(null);
useEffect(() => {
if (!openFile) {
setViewer(null);
return;
}
let cancelled = false;
setViewer({ path: openFile, state: 'loading' });
(async () => {
try {
const result = await api.projects.viewFile(projectId, openFile);
if (cancelled) return;
setViewer({ path: openFile, state: 'ready', result });
} catch (err) {
if (cancelled) return;
let message: string;
if (err instanceof ApiError) {
const apiMsg =
typeof err.body === 'object' &&
err.body !== null &&
'error' in err.body
? String((err.body as { error: unknown }).error)
: err.message;
if (err.status === 404) {
message = 'File not found';
} else if (apiMsg.toLowerCase().includes('too large')) {
message = 'File too large to view';
} else if (
apiMsg.toLowerCase().includes('outside') ||
apiMsg.toLowerCase().includes('not a file') ||
apiMsg.toLowerCase().includes('path')
) {
message = 'Cannot view files outside project';
} else {
message = apiMsg;
}
} else if (err instanceof Error) {
message = err.message;
} else {
message = 'Failed to load file';
}
setViewer({ path: openFile, state: 'error', error: message });
}
})();
return () => {
cancelled = true;
};
}, [openFile, projectId]);
// Root errors / loading
const rootEntries = cache.get('');
const rootLoading = loadingDirs.has('') && !rootEntries;
const rootError = dirErrors.get('');
return (
<div className="flex flex-col h-full min-h-0">
<div className="px-2 py-1.5 border-b border-border bg-muted/20">
<input
type="text"
value={filterDraft}
onChange={(e) => onFilterInput(e.target.value)}
placeholder="Filter files..."
className="w-full px-2 py-1 text-xs bg-background border border-border rounded outline-none focus:border-ring"
aria-label="Filter files"
/>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[minmax(0,260px)_1fr]">
<div
ref={treeRef}
tabIndex={0}
onKeyDown={onTreeKeyDown}
className="overflow-y-auto border-r border-border outline-none focus:ring-1 focus:ring-inset focus:ring-ring/40"
role="tree"
aria-label="Project files"
>
{rootLoading && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{rootError && (
<div className="text-xs text-destructive px-2 py-1.5">
{rootError}
</div>
)}
{!rootLoading && !rootError && filterActive && (
<ul className="list-none">
{filterResults.length === 0 ? (
<li className="text-xs text-muted-foreground px-2 py-1.5">
No matches
</li>
) : (
filterResults.map((entry) => {
const isActive =
entry.kind === 'file' && openFile === entry.path;
const isHighlight = highlightedPath === entry.path;
return (
<li key={entry.path}>
<div
className={cn(
'flex items-center gap-1 px-2 py-0.5 text-xs cursor-default rounded hover:bg-muted/60',
isActive && 'bg-muted',
isHighlight && 'ring-1 ring-ring/40'
)}
onClick={() => {
setHighlightedPath(entry.path);
if (entry.kind === 'dir') {
toggleDir(entry.path);
} else {
selectFile(entry.path);
}
}}
>
{entry.kind === 'dir' ? (
<Folder size={12} className="text-muted-foreground shrink-0" />
) : (
<FileText size={12} className="text-muted-foreground shrink-0" />
)}
<span className="truncate">{entry.path}</span>
</div>
</li>
);
})
)}
</ul>
)}
{!rootLoading && !rootError && !filterActive && rootEntries && (
<TreeNode
parentPath=""
entries={rootEntries}
cache={cache}
expanded={expandedSet}
openFile={openFile}
highlightedPath={highlightedPath}
depth={0}
onToggleDir={toggleDir}
onSelectFile={selectFile}
setHighlightedPath={setHighlightedPath}
/>
)}
</div>
<div className="flex flex-col min-h-0">
{!openFile && (
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
Select a file to view
</div>
)}
{openFile && (
<>
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<span
className="text-xs font-mono truncate"
title={openFile}
>
{basename(openFile)}
</span>
<button
type="button"
onClick={closeOpenFile}
className="p-0.5 hover:bg-muted rounded shrink-0"
aria-label="Close file"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{viewer?.state === 'loading' && (
<div className="text-xs text-muted-foreground px-2 py-1.5">
Loading...
</div>
)}
{viewer?.state === 'error' && (
<div className="text-xs text-destructive px-2 py-1.5">
{viewer.error}
</div>
)}
{viewer?.state === 'ready' && viewer.result && (
<div className="p-2">
{viewer.result.truncated && (
<div className="text-[11px] text-muted-foreground mb-1 px-2 py-1 rounded bg-muted/40 border border-border">
Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total.
</div>
)}
<CodeBlock code={viewer.result.content} lang={deriveLang(openFile)} />
</div>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import type { Pane } from '@/api/types';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
pane: Pane;
onClose: () => void;
className?: string;
children: ReactNode;
}
export function PaneShell({ pane, onClose, className, children }: Props) {
const label = pane.kind === 'chat' ? 'Chat' : 'Files';
return (
<div className={cn('flex flex-col h-full min-h-0 border-r border-border last:border-r-0', className)}>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<button
type="button"
onClick={onClose}
className="p-0.5 hover:bg-muted rounded"
aria-label="Close pane"
>
<X size={12} />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 min-w-32 overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="context-menu-checkbox-item-indicator"
>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="context-menu-radio-item-indicator"
>
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-[96px] overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuPortal,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
}