Adds assistants.
This commit is contained in:
@@ -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
37
app/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
187
app/src/components/AgentTerminal.tsx
Normal file
187
app/src/components/AgentTerminal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
301
app/src/components/AssistantDock.tsx
Normal file
301
app/src/components/AssistantDock.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user