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; 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; } 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(null); const [archived, setArchived] = useState([]); // 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 => { 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 (
{isEmpty ? (

No conversations yet. Send a message to start.

) : ( <> {openChats.length > 0 && ( <>

Conversations

{openChats.map((c) => ( ))}
)} {archivedChats.length > 0 && ( <>

Archived

{archivedChats.map((c) => ( ))}
)} )}
); }