import { useCallback, useState } from "react" import { Bot, ChevronDown, History, Maximize2, Minimize2, Play, RotateCw, Terminal as TerminalIcon, Trash2, } from "lucide-react" import { AgentTerminal } from "@/components/AgentTerminal" import { useAgents, useAgentHistory, useAgentSessions, useDeleteAgentSession, } from "@/services/api/hooks" import { cn } from "@/lib/utils" // The reserved name that launches the plain login shell (backend TERMINAL_AGENT). const SHELL = "terminal" interface Active { agent: string resumeId?: string mode?: "continue" resumeSessionId?: string key: number // bump to force a fresh terminal mount (relaunch / resume) } // A global, persistent assistant surface: a floating action button that opens an // overlay panel. It lives in Layout (which does not remount on navigation) and // keeps the terminal mounted while minimized, so the agent session and its // WebSocket survive as you click around the rest of the dashboard. export function AssistantDock() { const { data: agents } = useAgents() const { data: sessions } = useAgentSessions() const deleteSession = useDeleteAgentSession() const [open, setOpen] = useState(false) const [expanded, setExpanded] = useState(false) const [showSessions, setShowSessions] = useState(false) const [active, setActive] = useState(null) const [seq, setSeq] = useState(0) const { data: history } = useAgentHistory(showSessions) const launch = ( agent: string, opts?: { resumeId?: string; mode?: "continue"; resumeSessionId?: string }, ) => { const key = seq + 1 setSeq(key) setActive({ agent, resumeId: opts?.resumeId, mode: opts?.mode, resumeSessionId: opts?.resumeSessionId, key, }) setOpen(true) setShowSessions(false) } const relaunch = () => active && launch(active.agent) const onSession = useCallback(() => { /* session id confirmed; list auto-refreshes via its 5s poll */ }, []) const runningCount = sessions?.filter((s) => s.running).length ?? 0 const pill = "text-xs px-2.5 py-1 rounded-full border transition-colors inline-flex items-center gap-1.5" const pillIdle = "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)]" const iconBtn = "p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors" return ( <> {/* FAB — hidden while the panel is open */} {!open && ( )} {/* Overlay panel — kept mounted (translated off-screen when closed) so the terminal's WebSocket survives minimize. */}
{/* Header */}
{agents?.map((a) => ( ))} {/* resume: chip = open the agent's own in-terminal picker. Agents that expose a machine-readable session list appear in the History popover instead (no chip needed). */} {agents?.some((a) => a.available && a.can_continue && !a.can_list_sessions) && ( resume: )} {agents ?.filter((a) => a.available && a.can_continue && !a.can_list_sessions) .map((a) => ( ))}
{active && ( )}
{/* Body */}
{active ? ( ) : (
Pick an agent above to start a session.
)} {/* Sessions popover */} {showSessions && (
Sessions
{sessions && sessions.length > 0 ? (
{sessions.map((s) => (
{s.agent} {s.id} {s.running ? `${s.clients} client${s.clients === 1 ? "" : "s"}` : `exit ${s.exit_code ?? "?"}`}
))}
) : (
No live sessions.
)} {/* Agent-native history — past conversations each agent stored for this directory (from its declared session-list command). */} {history && history.length > 0 && ( <>
History
{history.map((h) => ( ))}
)}
)}
) } function formatTime(t: number | string | null): string { if (t == null) return "" if (typeof t === "number") return new Date(t).toLocaleString() return t }