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:
@@ -1,13 +1,18 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { usePanes } from '@/hooks/usePanes';
|
||||
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
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 type { Chat, WorkspacePane } from '@/api/types';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { FileBrowserPane } from '@/components/panes/FileBrowserPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -16,324 +21,341 @@ interface Props {
|
||||
}
|
||||
|
||||
const MAX_PANES = 5;
|
||||
const STORAGE_KEY = 'boocode.workspace.panes';
|
||||
|
||||
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 generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
function emptyPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
function loadPanes(sessionId: string): WorkspacePane[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_KEY}.${sessionId}`);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as WorkspacePane[];
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePanes(sessionId: string, panes: WorkspacePane[]): void {
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_KEY}.${sessionId}`, JSON.stringify(panes));
|
||||
} catch { /* quota or disabled */ }
|
||||
}
|
||||
|
||||
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);
|
||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => {
|
||||
return loadPanes(sessionId) ?? [emptyPane()];
|
||||
});
|
||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const chatsRef = useRef<Chat[]>([]);
|
||||
chatsRef.current = chats;
|
||||
|
||||
// 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]);
|
||||
let cancelled = false;
|
||||
api.chats.listForSession(sessionId).then((list) => {
|
||||
if (cancelled) return;
|
||||
setChats(list);
|
||||
const openChat = list.find((c) => c.status === 'open');
|
||||
if (openChat) {
|
||||
setPanes((prev) => {
|
||||
if (prev.length === 1 && prev[0]!.kind === 'empty') {
|
||||
return [chatPane(openChat.id)];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId]);
|
||||
|
||||
// Tracks an in-flight create() call so rapid open_file_in_browser events
|
||||
// don't race to each spawn a new file_browser pane. While a create is in
|
||||
// progress the subsequent events wait for it and update the same pane.
|
||||
const creatingRef = useRef<{ id: string; promise: Promise<string> } | null>(
|
||||
null
|
||||
);
|
||||
useEffect(() => {
|
||||
savePanes(sessionId, panes);
|
||||
}, [sessionId, panes]);
|
||||
|
||||
// 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 () => {
|
||||
// If a create is already in flight, wait for it to finish then update
|
||||
// the newly-created pane rather than spawning a second one.
|
||||
if (creatingRef.current) {
|
||||
const { id: pendingId, promise } = creatingRef.current;
|
||||
const resolvedId = await promise;
|
||||
const targetId = resolvedId || pendingId;
|
||||
const current = panesRef.current;
|
||||
const fb = current?.find((p) => p.id === targetId);
|
||||
const nextState: FileBrowserPaneState = {
|
||||
...(fb?.kind === 'file_browser' ? fb.state : {}),
|
||||
open_file: event.path,
|
||||
};
|
||||
await update(targetId, { state: nextState });
|
||||
setActiveId(targetId);
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
// Reserve the slot immediately so concurrent events see the flag.
|
||||
const createPromise = (async (): Promise<string> => {
|
||||
const newPane = await create({ kind: 'file_browser' });
|
||||
return newPane.id;
|
||||
})();
|
||||
// Use a stable object; id is filled in once resolved.
|
||||
const entry: { id: string; promise: Promise<string> } = {
|
||||
id: '',
|
||||
promise: createPromise,
|
||||
};
|
||||
creatingRef.current = entry;
|
||||
try {
|
||||
const newId = await createPromise;
|
||||
entry.id = newId;
|
||||
const nextState: FileBrowserPaneState = {
|
||||
open_file: event.path,
|
||||
filter: '',
|
||||
expanded_dirs: [],
|
||||
};
|
||||
await update(newId, { state: nextState });
|
||||
setActiveId(newId);
|
||||
} finally {
|
||||
if (creatingRef.current === entry) {
|
||||
creatingRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
if (event.type === 'chat_created' && event.session_id === sessionId) {
|
||||
setChats((prev) => [event.chat, ...prev]);
|
||||
}
|
||||
if (event.type === 'chat_updated') {
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c
|
||||
));
|
||||
}
|
||||
if (event.type === 'chat_closed') {
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === event.chat_id ? { ...c, status: 'closed' as const } : c
|
||||
));
|
||||
removeChatFromPanes(event.chat_id);
|
||||
}
|
||||
});
|
||||
}, [create, update]);
|
||||
}, [sessionId]);
|
||||
|
||||
const handleClose = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await remove(id);
|
||||
} catch {
|
||||
/* error surfaced via hook state */
|
||||
function removeChatFromPanes(chatId: string) {
|
||||
setPanes((prev) => prev.map((p) => {
|
||||
const idx = p.chatIds.indexOf(chatId);
|
||||
if (idx < 0) return p;
|
||||
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) {
|
||||
return { ...p, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
},
|
||||
[remove]
|
||||
);
|
||||
const nextActiveIdx = Math.min(p.activeChatIdx, nextIds.length - 1);
|
||||
return {
|
||||
...p,
|
||||
chatIds: nextIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: nextIds[nextActiveIdx],
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
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 */
|
||||
const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const existing = pane.chatIds.indexOf(chatId);
|
||||
if (existing >= 0) {
|
||||
next[paneIdx] = { ...pane, kind: 'chat', chatId, activeChatIdx: existing };
|
||||
} else {
|
||||
const newIds = [...pane.chatIds, chatId];
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
kind: 'chat',
|
||||
chatId,
|
||||
chatIds: newIds,
|
||||
activeChatIdx: newIds.length - 1,
|
||||
};
|
||||
}
|
||||
},
|
||||
[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';
|
||||
return next;
|
||||
});
|
||||
setActivePaneIdx(paneIdx);
|
||||
}, []);
|
||||
|
||||
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]
|
||||
);
|
||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const chatId = pane.chatIds[tabIdx];
|
||||
if (!chatId) return prev;
|
||||
next[paneIdx] = { ...pane, chatId, activeChatIdx: tabIdx };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading && !panes) return <PaneSkeleton />;
|
||||
if (error && !panes) return <PaneError message={error} onRetry={refresh} />;
|
||||
if (!panes) return <PaneSkeleton />;
|
||||
const removeTab = useCallback((paneIdx: number, chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) {
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
} else {
|
||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||
next[paneIdx] = {
|
||||
...pane,
|
||||
chatIds: nextIds,
|
||||
activeChatIdx: nextActiveIdx,
|
||||
chatId: nextIds[nextActiveIdx],
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createChat = useCallback(async (paneIdx: number) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
setChats((prev) => [chat, ...prev]);
|
||||
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
|
||||
openChatInPane(paneIdx, chat.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
|
||||
}
|
||||
}, [sessionId, openChatInPane]);
|
||||
|
||||
const closeChat = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
await api.chats.update(chatId, { status: 'closed' });
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === chatId ? { ...c, status: 'closed' as const } : c
|
||||
));
|
||||
removeChatFromPanes(chatId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to close chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteChat = useCallback(async (chatId: string) => {
|
||||
try {
|
||||
await api.chats.remove(chatId);
|
||||
setChats((prev) => prev.filter((c) => c.id !== chatId));
|
||||
removeChatFromPanes(chatId);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameChat = useCallback(async (chatId: string, name: string) => {
|
||||
try {
|
||||
await api.chats.update(chatId, { name });
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === chatId ? { ...c, name } : c
|
||||
));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to rename chat');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showLandingPage = useCallback((paneIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
const pane = next[paneIdx]!;
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
|
||||
if (kind === 'terminal') {
|
||||
toast('Terminal panes coming in BooTerm');
|
||||
return;
|
||||
}
|
||||
if (kind === 'agent') {
|
||||
toast('Agent panes coming in BooCoder');
|
||||
return;
|
||||
}
|
||||
setPanes((prev) => {
|
||||
if (prev.length >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, emptyPane()];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removePane = useCallback((idx: number) => {
|
||||
setPanes((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLandingSend = useCallback(async (paneIdx: number, content: string) => {
|
||||
try {
|
||||
const chat = await api.chats.create(sessionId);
|
||||
setChats((prev) => [chat, ...prev]);
|
||||
sessionEvents.emit({ type: 'chat_created', chat, session_id: sessionId });
|
||||
openChatInPane(paneIdx, chat.id);
|
||||
await api.messages.send(chat.id, content);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to send');
|
||||
}
|
||||
}, [sessionId, openChatInPane]);
|
||||
|
||||
function chatsForPane(pane: WorkspacePane): Chat[] {
|
||||
return pane.chatIds
|
||||
.map((id) => chats.find((c) => c.id === id))
|
||||
.filter((c): c is Chat => c !== undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs px-2 py-1 rounded hover:bg-muted',
|
||||
panes.length >= MAX_PANES && 'opacity-40 cursor-not-allowed hover:bg-transparent'
|
||||
)}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
Split
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('chat')}>
|
||||
<MessageSquare size={14} /> Chat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('terminal')}>
|
||||
<Terminal size={14} /> Terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => addSplitPane('agent')}>
|
||||
<Bot size={14} /> Agent
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center border-b border-border bg-muted/20"
|
||||
role="tablist"
|
||||
className="flex-1 grid min-h-0"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${panes.length}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{panes.map((pane, idx) => (
|
||||
<PaneTab
|
||||
<div
|
||||
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}
|
||||
className={cn(
|
||||
'flex flex-col h-full min-h-0 border-r border-border last:border-r-0',
|
||||
idx === activePaneIdx && 'ring-1 ring-inset ring-ring/20'
|
||||
)}
|
||||
onClick={() => setActivePaneIdx(idx)}
|
||||
>
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
onClose={() => void handleClose(pane.id)}
|
||||
>
|
||||
{pane.kind === 'chat' ? (
|
||||
<ChatPane sessionId={sessionId} />
|
||||
tabs={chatsForPane(pane)}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onNewChat={() => void createChat(idx)}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onClose={closeChat}
|
||||
onDelete={deleteChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{pane.kind === 'chat' && pane.chatId ? (
|
||||
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
|
||||
) : (
|
||||
<FileBrowserPane
|
||||
pane={pane}
|
||||
<SessionLandingPage
|
||||
sessionId={sessionId}
|
||||
projectId={projectId}
|
||||
onStateChange={(state) =>
|
||||
void update(pane.id, { state })
|
||||
}
|
||||
chats={chats}
|
||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||
onSend={(content) => void handleLandingSend(idx, content)}
|
||||
onReopenChat={async (chatId) => {
|
||||
await api.chats.update(chatId, { status: 'open' });
|
||||
setChats((prev) => prev.map((c) =>
|
||||
c.id === chatId ? { ...c, status: 'open' as const } : c
|
||||
));
|
||||
openChatInPane(idx, chatId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PaneShell>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user