- Add ComparePane.tsx: side-by-side AI response comparison - Add Memory.tsx: memory management page with CRUD UI - Add McpPermissionDialog.tsx: MCP tool permission approval dialog - Add McpResponseDisplay.tsx: MCP response visualization - Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience - Add EmptyState.tsx: contextual empty state component - Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference - Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard - Add useDraftPersistence.ts: draft message persistence hook - Add useTerminals.ts: terminal session management hook - Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities - Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes - Extend hooks: useTerminalSocket, useSessionStream test suite - Update pages: Home, Project — workspace layout and session flow
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Archive, ChevronLeft, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import mascot from '@/assets/brand/banner-mascot.png';
|
|
import { EmptyState } from '@/components/EmptyState';
|
|
import { ChatInput } from '@/components/ChatInput';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { api } from '@/api/client';
|
|
import type { Chat } from '@/api/types';
|
|
import { formatRelative } from '@/lib/format';
|
|
|
|
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>;
|
|
onArchiveChat: (chatId: string) => Promise<void>;
|
|
onDeleteChat: (chatId: string) => Promise<void>;
|
|
// Controlled session-history view: the new-chat hero is the default; the
|
|
// header's "Session history" button flips this on (and Back flips it off).
|
|
openHistory: boolean;
|
|
onCloseHistory: () => void;
|
|
}
|
|
|
|
|
|
function byRecent(a: Chat, b: Chat): number {
|
|
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
|
}
|
|
|
|
// Pick the row icon by the chat's seed name: coder and terminal panes create
|
|
// placeholder chats named 'BooCoder' / 'Terminal' (see useWorkspacePanes
|
|
// chatNameForPaneKind + the coder chat-resolve). A name heuristic keeps this
|
|
// frontend-only — matches ProjectSidebar's isCoderSessionName approach.
|
|
function iconForChat(name: string | null) {
|
|
if (name === 'BooCoder') return Code;
|
|
if (name === 'Terminal') return Terminal;
|
|
return MessageSquare;
|
|
}
|
|
|
|
export function SessionLandingPage({
|
|
projectId,
|
|
sessionId,
|
|
agentId,
|
|
onAgentChange,
|
|
onSend,
|
|
onSkillInvoke,
|
|
createChat,
|
|
chats,
|
|
onOpenChat,
|
|
onUnarchiveChat,
|
|
onArchiveChat,
|
|
onDeleteChat,
|
|
openHistory,
|
|
onCloseHistory,
|
|
}: Props) {
|
|
const [chatId, setChatId] = useState<string | null>(null);
|
|
const [archived, setArchived] = useState<Chat[]>([]);
|
|
// Plain Cancel/Confirm delete (no type-to-confirm), mirroring ProjectSidebar.
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string | null } | null>(null);
|
|
|
|
// 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 h-full">
|
|
{!openHistory ? (
|
|
/* Landing hero — BooCode mascot + prompt, vertically centered to
|
|
match the coder pane's empty state (a flex-1 fill).
|
|
Session history lives in the header (see Session.tsx row 2). */
|
|
<div className="flex flex-col items-center justify-center text-center gap-4 h-full">
|
|
<img
|
|
src={mascot}
|
|
alt="BooCode"
|
|
draggable={false}
|
|
className="w-20 h-auto select-none"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
Send a message to start a new conversation
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-1.5 mb-3">
|
|
<button
|
|
type="button"
|
|
onClick={onCloseHistory}
|
|
className="inline-flex items-center gap-1 -ml-1 px-2 min-h-[44px] rounded text-sm text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
|
aria-label="Back to new conversation"
|
|
>
|
|
<ChevronLeft size={16} className="shrink-0" />
|
|
Back
|
|
</button>
|
|
<h2 className="text-sm font-medium ml-auto mr-1">Session history</h2>
|
|
</div>
|
|
{isEmpty ? (
|
|
<EmptyState
|
|
icon={<MessageSquare size={40} strokeWidth={1.5} />}
|
|
title="No conversations"
|
|
description="Your chat history will appear here"
|
|
/>
|
|
) : (<>
|
|
{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) => {
|
|
const Icon = iconForChat(c.name);
|
|
return (
|
|
<div
|
|
key={c.id}
|
|
className="group/row flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpenChat(c.id)}
|
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
|
>
|
|
<Icon 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 className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
|
|
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-9"
|
|
aria-label="Archive chat"
|
|
title="Archive"
|
|
>
|
|
<Archive size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
|
|
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-destructive/20 hover:text-destructive max-md:size-9"
|
|
aria-label="Delete chat"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</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) => (
|
|
<div
|
|
key={c.id}
|
|
className="group/arch flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => void restoreAndOpen(c.id)}
|
|
title="Restore and open"
|
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
|
>
|
|
<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 max-md:opacity-100"
|
|
/>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
|
|
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 max-md:opacity-100 motion-reduce:transition-none transition-opacity"
|
|
aria-label="Delete chat"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</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}
|
|
/>
|
|
<Dialog
|
|
open={deleteConfirm !== null}
|
|
onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete chat?</DialogTitle>
|
|
<DialogDescription>
|
|
Permanently deletes "{deleteConfirm?.name ?? 'New chat'}" and all its messages. This cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
|
|
setDeleteConfirm(null);
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|