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