Files
boocode/apps/web/src/components/SessionLandingPage.tsx
indifferentketchup 2fd7e5bf97 feat(web): workspace panes & tabs overhaul
A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):

- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
  ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
  lands the fork beside the original instead of replacing the active pane.
  openChatInNewPane detaches the chat from any pane already holding it
  (one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
  term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
  artifact) and its dead code; the artifact-pane machinery is left orphaned for
  a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
  archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
  oldest chat/empty pane instead of discarding them; terminal/coder panes close
  as before. Reopen strips the restored chatIds from all live panes first, so a
  relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
  open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
  nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
  array into the persisted envelope so it survives reload. Hydrate/persist
  normalize the legacy bare-array shape. appendClosed dedupes a value-identical
  top entry to neutralize the StrictMode double-invoke of the setPanes updater.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:15:03 +00:00

201 lines
7.5 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { ChatInput } from '@/components/ChatInput';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
interface Props {
projectId: string;
sessionId: string;
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void;
// Slash-command (skill) send from the landing page. The parent creates the
// chat, assigns it to the pane (so it transitions to ChatPane), and invokes
// the skill — same transition the text send uses. See useSessionChats.
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
createChat: () => Promise<{ id: string }>;
// Session history: the session's open chats (live), and callbacks to open one
// in THIS pane / restore an archived one. Archived chats are fetched here
// (the default open-only list excludes them).
chats: Chat[];
onOpenChat: (chatId: string) => void;
onUnarchiveChat: (chatId: string) => Promise<void>;
}
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
if (s < 60) return 'just now';
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
function byRecent(a: Chat, b: Chat): number {
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
}
export function SessionLandingPage({
projectId,
sessionId,
agentId,
onAgentChange,
onSend,
onSkillInvoke,
createChat,
chats,
onOpenChat,
onUnarchiveChat,
}: Props) {
const [chatId, setChatId] = useState<string | null>(null);
const [archived, setArchived] = useState<Chat[]>([]);
// Archived chats aren't in the default (open-only) list, so fetch them. One
// shot on session change — the history view is transient (pick a chat and
// it's gone), so slight staleness is fine; reopening the pane refetches.
useEffect(() => {
let cancelled = false;
api.chats
.listForSession(sessionId, { status: 'archived' })
.then((list) => { if (!cancelled) setArchived(list); })
.catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
const ensureChat = useCallback(async (): Promise<string> => {
if (chatId) return chatId;
try {
const chat = await createChat();
setChatId(chat.id);
return chat.id;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create chat');
throw err;
}
}, [chatId, createChat]);
const handleSend = useCallback(async (content: string) => {
const text = content.trim();
if (!text) return;
try {
await ensureChat();
onSend(text);
} catch {
// Error already surfaced via toast.
}
}, [ensureChat, onSend]);
// Route to the parent, which creates the chat, assigns it to the pane (so the
// pane transitions to ChatPane and subscribes to the stream), then invokes the
// skill — mirroring the text-send transition. Doing the skill invoke locally
// (without the pane assignment) left the landing pane stuck/blank.
const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
}, [onSkillInvoke]);
const restoreAndOpen = useCallback(async (id: string) => {
try {
await onUnarchiveChat(id);
onOpenChat(id);
} catch {
// onUnarchiveChat surfaces its own toast.
}
}, [onUnarchiveChat, onOpenChat]);
const openChats = [...chats.filter((c) => c.status === 'open')].sort(byRecent);
const openIds = new Set(openChats.map((c) => c.id));
const archivedChats = archived.filter((c) => !openIds.has(c.id)).sort(byRecent);
const isEmpty = openChats.length === 0 && archivedChats.length === 0;
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
{isEmpty ? (
<p className="text-sm text-muted-foreground text-center py-8">
No conversations yet. Send a message to start.
</p>
) : (
<>
{openChats.length > 0 && (
<>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
Conversations
</h3>
<div className="space-y-0.5 mb-4">
{openChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onOpenChat(c.id)}
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
>
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
{c.last_message_preview && (
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
{c.last_message_preview}
</span>
)}
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
{formatRelative(c.updated_at)}
</span>
</button>
))}
</div>
</>
)}
{archivedChats.length > 0 && (
<>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
Archived
</h3>
<div className="space-y-0.5">
{archivedChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => void restoreAndOpen(c.id)}
title="Restore and open"
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
>
<Archive size={14} className="shrink-0" />
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
<RotateCcw
size={13}
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
/>
</button>
))}
</div>
</>
)}
</>
)}
</div>
</div>
<ChatInput
disabled={false}
projectId={projectId}
sessionId={sessionId}
agentId={agentId ?? null}
onAgentChange={onAgentChange}
onSend={handleSend}
onSlashCommand={handleSlashCommand}
chatId={chatId ?? undefined}
chatLabel="Chat"
messages={[]}
modelContextLimit={null}
/>
</div>
);
}