From fb31e63d1035a98512c7d832d584079f98a970fe Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Fri, 15 May 2026 15:46:14 +0000 Subject: [PATCH] =?UTF-8?q?batch3=20T7:=20pane=20components=20=E2=80=94=20?= =?UTF-8?q?PaneShell,=20ChatPane,=20FileBrowserPane,=20PaneTab,=20Workspac?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/web/src/components/PaneTab.tsx | 116 ++++ apps/web/src/components/Workspace.tsx | 297 ++++++++ apps/web/src/components/panes/ChatPane.tsx | 39 ++ .../src/components/panes/FileBrowserPane.tsx | 637 ++++++++++++++++++ apps/web/src/components/panes/PaneShell.tsx | 31 + apps/web/src/components/ui/context-menu.tsx | 263 ++++++++ 6 files changed, 1383 insertions(+) create mode 100644 apps/web/src/components/PaneTab.tsx create mode 100644 apps/web/src/components/Workspace.tsx create mode 100644 apps/web/src/components/panes/ChatPane.tsx create mode 100644 apps/web/src/components/panes/FileBrowserPane.tsx create mode 100644 apps/web/src/components/panes/PaneShell.tsx create mode 100644 apps/web/src/components/ui/context-menu.tsx diff --git a/apps/web/src/components/PaneTab.tsx b/apps/web/src/components/PaneTab.tsx new file mode 100644 index 0000000..c05033c --- /dev/null +++ b/apps/web/src/components/PaneTab.tsx @@ -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) => void; + onDragOver: (e: DragEvent) => void; + onDrop: (e: DragEvent) => 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 ( + + +
+ + + {label} + + +
+
+ + + Split + + onSplit('chat')}> + Chat + + onSplit('file_browser')}> + File Browser + + + + + Close + Close others + + Close to the right + + Close all + +
+ ); +} diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx new file mode 100644 index 0000000..380b5e9 --- /dev/null +++ b/apps/web/src/components/Workspace.tsx @@ -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 ( +
+
+
+ Loading panes... +
+
+ ); +} + +function PaneError({ + message, + onRetry, +}: { + message: string; + onRetry: () => void | Promise; +}) { + return ( +
+ {message} + +
+ ); +} + +export function Workspace({ sessionId, projectId }: Props) { + const { panes, loading, error, create, update, remove, refresh } = + usePanes(sessionId); + const [activeId, setActiveId] = useState(null); + const draggingIdRef = useRef(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(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) => { + draggingIdRef.current = id; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', id); + }, + [] + ); + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }, []); + + const handleDrop = useCallback( + (targetIdx: number) => async (e: DragEvent) => { + 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 ; + if (error && !panes) return ; + if (!panes) return ; + + return ( +
+
+ {panes.map((pane, idx) => ( + 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)} + /> + ))} + +
+ {panes.length === 0 ? ( +
+ No panes. Click + to add one. +
+ ) : ( +
+ {panes.map((pane) => ( + void handleClose(pane.id)} + > + {pane.kind === 'chat' ? ( + + ) : ( + + void update(pane.id, { state }) + } + /> + )} + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/panes/ChatPane.tsx b/apps/web/src/components/panes/ChatPane.tsx new file mode 100644 index 0000000..905d043 --- /dev/null +++ b/apps/web/src/components/panes/ChatPane.tsx @@ -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(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 ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/panes/FileBrowserPane.tsx b/apps/web/src/components/panes/FileBrowserPane.tsx new file mode 100644 index 0000000..ae4a2d0 --- /dev/null +++ b/apps/web/src/components/panes/FileBrowserPane.tsx @@ -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 = { + 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; + expanded: Set; + 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 ( +
    + {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 ( +
  • +
    { + setHighlightedPath(fullPath); + if (entry.kind === 'dir') { + onToggleDir(fullPath); + } else { + onSelectFile(fullPath); + } + }} + > + {entry.kind === 'dir' ? ( + + ) : ( + + )} + {entry.kind === 'dir' ? ( + + ) : ( + + )} + {entry.name} +
    + {entry.kind === 'dir' && isExpanded && cache.has(fullPath) && ( + + )} +
  • + ); + })} +
+ ); +} + +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 | 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>(new Map()); + const [loadingDirs, setLoadingDirs] = useState>(new Set()); + const [dirErrors, setDirErrors] = useState>(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(() => { + 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(() => { + 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(() => { + 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(null); + const treeRef = useRef(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) { + 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 ( +
+
+ 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" + /> +
+
+
+ {rootLoading && ( +
+ Loading... +
+ )} + {rootError && ( +
+ {rootError} +
+ )} + {!rootLoading && !rootError && filterActive && ( +
    + {filterResults.length === 0 ? ( +
  • + No matches +
  • + ) : ( + filterResults.map((entry) => { + const isActive = + entry.kind === 'file' && openFile === entry.path; + const isHighlight = highlightedPath === entry.path; + return ( +
  • +
    { + setHighlightedPath(entry.path); + if (entry.kind === 'dir') { + toggleDir(entry.path); + } else { + selectFile(entry.path); + } + }} + > + {entry.kind === 'dir' ? ( + + ) : ( + + )} + {entry.path} +
    +
  • + ); + }) + )} +
+ )} + {!rootLoading && !rootError && !filterActive && rootEntries && ( + + )} +
+
+ {!openFile && ( +
+ Select a file to view +
+ )} + {openFile && ( + <> +
+ + {basename(openFile)} + + +
+
+ {viewer?.state === 'loading' && ( +
+ Loading... +
+ )} + {viewer?.state === 'error' && ( +
+ {viewer.error} +
+ )} + {viewer?.state === 'ready' && viewer.result && ( +
+ {viewer.result.truncated && ( +
+ Showing first {viewer.result.bytes_returned} bytes; file is {viewer.result.total_bytes} bytes total. +
+ )} + +
+ )} +
+ + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/panes/PaneShell.tsx b/apps/web/src/components/panes/PaneShell.tsx new file mode 100644 index 0000000..2e5d2f3 --- /dev/null +++ b/apps/web/src/components/panes/PaneShell.tsx @@ -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 ( +
+
+ {label} + +
+
{children}
+
+ ); +} diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..ffa6fef --- /dev/null +++ b/apps/web/src/components/ui/context-menu.tsx @@ -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) { + return +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuPortal, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuGroup, + ContextMenuLabel, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuSubContent, +}