Adds assistants.

This commit is contained in:
2026-07-01 15:11:45 -07:00
parent 6929bd89d3
commit ba2001df49
18 changed files with 1720 additions and 4 deletions

View File

@@ -13,6 +13,8 @@
"dependencies": {
"@tailwindcss/vite": "^4.2.0",
"@tanstack/react-query": "^5.90.21",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",

37
app/pnpm-lock.yaml generated
View File

@@ -14,6 +14,12 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -427,66 +433,79 @@ packages:
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.57.1':
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.57.1':
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.57.1':
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.57.1':
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.57.1':
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.57.1':
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.57.1':
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.57.1':
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.57.1':
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.57.1':
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
@@ -556,24 +575,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.2.0':
resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.2.0':
resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.2.0':
resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.2.0':
resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==}
@@ -710,6 +733,12 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1050,24 +1079,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -1914,6 +1947,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@xterm/addon-fit@0.11.0': {}
'@xterm/xterm@6.0.0': {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0

View File

@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react"
import { Maximize2, Minimize2 } from "lucide-react"
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
import "@xterm/xterm/css/xterm.css"
import { apiClient } from "@/services/api/client"
type Status = "connecting" | "connected" | "exited" | "closed" | "error"
interface AgentTerminalProps {
// Agent name to launch, or "terminal" for the plain login shell.
agent: string
// When set, resume this existing session instead of creating a new one.
resumeId?: string
// Fired once the backend confirms the session id (new or resumed).
onSession?: (id: string) => void
// Fill the parent container's height instead of using a fixed height.
fill?: boolean
// "continue": launch the agent with its resume_args (reopen its last convo).
mode?: "continue"
// Resume one of the agent's OWN past sessions by its id (agent-native).
resumeSessionId?: string
}
export function AgentTerminal({
agent,
resumeId,
onSession,
fill,
mode,
resumeSessionId,
}: AgentTerminalProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [status, setStatus] = useState<Status>("connecting")
const [detail, setDetail] = useState<string>("")
const [maximized, setMaximized] = useState(false)
useEffect(() => {
const term = new Terminal({
convertEol: false,
cursorBlink: true,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 13,
theme: { background: "#0a0a0a", foreground: "#d1d5db" },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(containerRef.current!)
try {
fit.fit()
} catch {
/* container not laid out yet */
}
const path = resumeId
? `/agents/${agent}/session?session=${encodeURIComponent(resumeId)}`
: resumeSessionId
? `/agents/${agent}/session?resume_session=${encodeURIComponent(resumeSessionId)}`
: mode === "continue"
? `/agents/${agent}/session?mode=continue`
: `/agents/${agent}/session`
const ws = new WebSocket(apiClient.wsUrl(path))
ws.binaryType = "arraybuffer"
const enc = new TextEncoder()
const sendResize = () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }))
}
}
ws.onopen = () => {
setStatus("connected")
sendResize()
// On resume, full-screen TUIs (opencode, claude) don't know a new client
// attached and won't repaint. A size change forces SIGWINCH → a full
// redraw of the current frame, so the prior session becomes visible.
if (resumeId) {
setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) return
ws.send(
JSON.stringify({ type: "resize", cols: Math.max(1, term.cols - 1), rows: term.rows }),
)
setTimeout(sendResize, 60)
}, 80)
}
term.focus()
}
ws.onmessage = (e) => {
if (typeof e.data === "string") {
let msg: { type?: string; id?: string; code?: number; error?: string }
try {
msg = JSON.parse(e.data)
} catch {
return
}
if (msg.type === "session" && msg.id) {
onSession?.(msg.id)
} else if (msg.type === "exit") {
setStatus("exited")
setDetail(`exit ${msg.code ?? "?"}`)
term.write(`\r\n\x1b[90m[process exited: ${msg.code ?? "?"}]\x1b[0m\r\n`)
} else if (msg.type === "error") {
setStatus("error")
setDetail(msg.error ?? "error")
term.write(`\r\n\x1b[31m[error: ${msg.error ?? "unknown"}]\x1b[0m\r\n`)
}
} else {
term.write(new Uint8Array(e.data as ArrayBuffer))
}
}
ws.onclose = () => setStatus((s) => (s === "exited" || s === "error" ? s : "closed"))
ws.onerror = () => setStatus((s) => (s === "connecting" ? "error" : s))
const dataDisp = term.onData((d) => {
if (ws.readyState === WebSocket.OPEN) ws.send(enc.encode(d))
})
const ro = new ResizeObserver(() => {
try {
fit.fit()
} catch {
/* ignore */
}
sendResize()
})
if (containerRef.current) ro.observe(containerRef.current)
return () => {
ro.disconnect()
dataDisp.dispose()
ws.close()
term.dispose()
}
// Remounting (new agent / resume target / mode) tears this down and restarts.
}, [agent, resumeId, mode, resumeSessionId, onSession])
const statusColor =
status === "connected"
? "text-green-400"
: status === "connecting"
? "text-amber-400"
: status === "error"
? "text-red-400"
: "text-[var(--muted)]"
const btn =
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
return (
<div
className={
maximized
? "fixed inset-0 z-50 bg-[var(--background)] flex flex-col"
: fill
? "h-full flex flex-col bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
: "bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
}
>
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] text-xs text-[var(--muted)]">
<span className="flex items-center gap-2">
<span>terminal: {agent}</span>
<span className={statusColor}>
{status}
{detail ? ` · ${detail}` : ""}
</span>
</span>
{/* In dock (fill) mode the surrounding panel owns sizing/expansion, so
the inner maximize is hidden (a fixed-inset child can't escape the
panel's transformed containing block anyway). */}
{!fill && (
<button
onClick={() => setMaximized((m) => !m)}
className={btn}
title={maximized ? "Restore" : "Maximize"}
>
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
)}
</div>
<div
ref={containerRef}
className={maximized || fill ? "flex-1 min-h-0 p-2" : "h-[560px] p-2"}
/>
</div>
)
}

View File

@@ -0,0 +1,301 @@
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<Active | null>(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 && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-4 right-4 z-40 w-14 h-14 rounded-full bg-[var(--primary)] text-black shadow-lg flex items-center justify-center hover:opacity-90 transition-opacity"
title="Assistant"
aria-label="Open assistant"
>
<Bot size={24} />
{runningCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-green-400 border-2 border-[var(--background)]" />
)}
</button>
)}
{/* Overlay panel — kept mounted (translated off-screen when closed) so the
terminal's WebSocket survives minimize. */}
<div
className={cn(
"fixed z-40 flex flex-col",
expanded
? "inset-4"
: "bottom-4 right-4 w-[760px] max-w-[calc(100vw-2rem)] h-[600px] max-h-[calc(100vh-2rem)]",
"bg-[var(--background)] border border-[var(--border)] rounded-xl shadow-2xl",
"transition-all duration-200",
open
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-[calc(100%+2rem)] pointer-events-none",
)}
aria-hidden={!open}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--border)]">
<Bot size={16} className="text-[var(--primary)] shrink-0" />
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<button
onClick={() => launch(SHELL)}
className={cn(
pill,
active?.agent === SHELL && !active.resumeId
? "border-[var(--primary)] text-[var(--foreground)]"
: pillIdle,
)}
title="Plain login shell in the castle repo"
>
<TerminalIcon size={13} /> Shell
</button>
{agents?.map((a) => (
<button
key={a.name}
onClick={() => a.available && launch(a.name)}
disabled={!a.available}
className={cn(
pill,
!a.available && "opacity-40 cursor-not-allowed",
active?.agent === a.name && !active.resumeId
? "border-[var(--primary)] text-[var(--foreground)]"
: pillIdle,
)}
title={a.available ? a.description ?? a.name : `${a.command} not installed`}
>
<Bot size={13} /> {a.name}
</button>
))}
{/* 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) && (
<span className="text-[10px] text-[var(--muted)] pl-1">resume:</span>
)}
{agents
?.filter((a) => a.available && a.can_continue && !a.can_list_sessions)
.map((a) => (
<button
key={`cont-${a.name}`}
onClick={() => launch(a.name, { mode: "continue" })}
className={cn(pill, pillIdle)}
title={`Open ${a.name}'s session picker in this directory`}
>
<RotateCw size={12} /> {a.name}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{active && (
<button onClick={relaunch} className={iconBtn} title="Restart session">
<RotateCw size={14} />
</button>
)}
<button
onClick={() => setShowSessions((s) => !s)}
className={cn(iconBtn, showSessions && "text-[var(--foreground)] bg-white/10")}
title="Sessions"
>
<History size={15} />
</button>
<button
onClick={() => setExpanded((e) => !e)}
className={iconBtn}
title={expanded ? "Shrink" : "Expand"}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
<button onClick={() => setOpen(false)} className={iconBtn} title="Minimize">
<ChevronDown size={16} />
</button>
</div>
</div>
{/* Body */}
<div className="relative flex-1 min-h-0 p-2">
{active ? (
<AgentTerminal
key={active.key}
agent={active.agent}
resumeId={active.resumeId}
mode={active.mode}
resumeSessionId={active.resumeSessionId}
onSession={onSession}
fill
/>
) : (
<div className="h-full flex items-center justify-center text-sm text-[var(--muted)]">
Pick an agent above to start a session.
</div>
)}
{/* Sessions popover */}
{showSessions && (
<div className="absolute inset-x-2 top-2 max-h-[calc(100%-1rem)] overflow-auto bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl">
<div className="px-3 py-2 text-xs font-semibold border-b border-[var(--border)]">
Sessions
</div>
{sessions && sessions.length > 0 ? (
<div className="divide-y divide-[var(--border)]">
{sessions.map((s) => (
<div key={s.id} className="flex items-center gap-2 px-3 py-2 text-xs">
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
s.running ? "bg-green-400" : "bg-[var(--muted)]",
)}
title={s.running ? "running" : "exited"}
/>
<span className="font-medium">{s.agent}</span>
<span className="text-[var(--muted)] font-mono">{s.id}</span>
<span className="text-[var(--muted)] ml-auto">
{s.running ? `${s.clients} client${s.clients === 1 ? "" : "s"}` : `exit ${s.exit_code ?? "?"}`}
</span>
<button
onClick={() => launch(s.agent, { resumeId: s.id })}
disabled={!s.running}
className={cn(
"p-1 rounded hover:bg-white/10 transition-colors",
s.running
? "text-[var(--muted)] hover:text-[var(--foreground)]"
: "opacity-30 cursor-not-allowed",
)}
title="Resume"
>
<Play size={13} />
</button>
<button
onClick={() => deleteSession.mutate(s.id)}
className="p-1 rounded text-[var(--muted)] hover:text-red-400 hover:bg-white/10 transition-colors"
title="Kill session"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
) : (
<div className="px-3 py-4 text-xs text-[var(--muted)]">
No live sessions.
</div>
)}
{/* Agent-native history — past conversations each agent stored for
this directory (from its declared session-list command). */}
{history && history.length > 0 && (
<>
<div className="px-3 py-2 text-xs font-semibold border-y border-[var(--border)]">
History
</div>
<div className="divide-y divide-[var(--border)]">
{history.map((h) => (
<button
key={`${h.agent}-${h.id}`}
onClick={() =>
launch(h.agent, { resumeSessionId: h.id })
}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left hover:bg-white/5 transition-colors"
title={`Resume in ${h.agent}`}
>
<RotateCw size={12} className="text-[var(--muted)] shrink-0" />
<span className="text-[var(--muted)]">{h.agent}</span>
<span className="truncate">{h.title || h.id}</span>
<span className="text-[var(--muted)] ml-auto shrink-0">
{formatTime(h.time)}
</span>
</button>
))}
</div>
</>
)}
</div>
)}
</div>
</div>
</>
)
}
function formatTime(t: number | string | null): string {
if (t == null) return ""
if (typeof t === "number") return new Date(t).toLocaleString()
return t
}

View File

@@ -18,6 +18,7 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useEventStream } from "@/services/api/hooks"
import { AssistantDock } from "@/components/AssistantDock"
type NavLeaf = { to: string; label: string; icon: LucideIcon; end?: boolean }
type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
@@ -228,6 +229,9 @@ export function Layout() {
>
<Outlet />
</main>
{/* Global assistant — persists across navigation (Layout doesn't remount). */}
<AssistantDock />
</div>
)
}

View File

@@ -76,6 +76,17 @@ class ApiClient {
streamUrl(path: string): string {
return `${this.baseUrl}${path}`
}
// Build a ws(s):// URL for a WebSocket endpoint. Handles both the cross-origin
// base (https://castle-api.<domain>) and the same-origin "/api" base.
wsUrl(path: string): string {
const absolute = this.baseUrl.startsWith("http")
? `${this.baseUrl}${path}`
: `${window.location.origin}${this.baseUrl}${path}`
const url = new URL(absolute)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
return url.toString()
}
}
export const apiClient = new ApiClient()

View File

@@ -17,6 +17,9 @@ import type {
MeshStatus,
NodeSummary,
NodeDetail,
AgentInfo,
AgentSessionInfo,
AgentHistoryEntry,
} from "@/types"
// Compat hook for the /deployments/{name} unified detail endpoint
@@ -208,6 +211,39 @@ export function useMeshStatus() {
})
}
export function useAgents() {
return useQuery({
queryKey: ["agents"],
queryFn: () => apiClient.get<AgentInfo[]>("/agents"),
staleTime: Infinity,
})
}
export function useAgentSessions() {
return useQuery({
queryKey: ["agent-sessions"],
queryFn: () => apiClient.get<AgentSessionInfo[]>("/agents/sessions"),
refetchInterval: 5_000,
})
}
export function useAgentHistory(enabled: boolean) {
return useQuery({
queryKey: ["agent-history"],
queryFn: () => apiClient.get<AgentHistoryEntry[]>("/agents/history"),
enabled,
staleTime: 10_000,
})
}
export function useDeleteAgentSession() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiClient.delete(`/agents/sessions/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ["agent-sessions"] }),
})
}
export function useEventStream() {
const qc = useQueryClient()

View File

@@ -156,6 +156,38 @@ export interface SSEHealthEvent {
timestamp: number
}
// Agent terminal UX
export interface AgentInfo {
name: string
command: string
available: boolean
cwd: string
description: string | null
can_continue: boolean
can_list_sessions: boolean
}
export interface AgentHistoryEntry {
agent: string
id: string
title: string
time: number | string | null
}
export interface AgentSessionInfo {
id: string
agent: string
command: string
cwd: string
created_at: number
running: boolean
exited: boolean
exit_code: number | null
cols: number | null
rows: number | null
clients: number
}
export interface SSEServiceActionEvent {
action: string
program: string