diff --git a/ui/src/dash/agents/agent-view.jsx b/ui/src/dash/agents/agent-view.jsx new file mode 100644 index 00000000..43eb67bd --- /dev/null +++ b/ui/src/dash/agents/agent-view.jsx @@ -0,0 +1,151 @@ +// hal0 v0.3 PR-8 — AgentView shell. +// +// AgentView is the `#agent` route. PR-8 split the original 974-LOC +// monolith (extras.jsx) into one file per tab; this shell is the tab +// nav + tab-content switch. +// +// Tab inventory (master plan §4 PR-8): +// - HermesChatTab (default, placeholder; PR-10 fills the composer) +// - PersonasTab (reads /api/agents/{id}/personas — PR-4 live) +// - SkillsTab (static skill catalog for v0.3) +// - MemoryTab (Cognee stats + "Peer memory" subsection folded +// in from the old Peers tab) +// - PluginsTab (wraps PluginTabHost from PR-7) +// +// Dropped vs v0.2.1: +// - Inbox tab — approvals UX moved to the sidebar pip (PR-6) and +// future inline approval cards in HermesChat (PR-10). +// - Peers standalone tab — folded into MemoryTab as the "Peer memory" +// subsection (the live MCP search Peers used is preserved). +// - Overview tab — content moved to SidebarAgentBlock (PR-6); the +// main pane now lands on HermesChatTab by default. +// +// Hash routes supported (parsed by main.jsx parseRoute): +// #agent → chat tab (default) +// #agent/chat → chat tab +// #agent/personas → personas tab +// #agent/skills → skills tab +// #agent/memory → memory tab +// #agent/memory?subsection=peer → memory tab scrolled to Peer memory +// #agent/plugins → plugins tab +// #peers (legacy) → redirected to #agent/memory?subsection=peer +// +// Window-globals build shim: components register on `window` and read +// each other via the same. Don't add ES module imports across dash/* +// — main.tsx's load order is the contract. + +const { useState: useStateAV, useEffect: useEffectAV } = React; + +const AGENT_TABS = [ + { id: "chat", label: "Chat" }, + { id: "personas", label: "Personas" }, + { id: "skills", label: "Skills" }, + { id: "memory", label: "Memory" }, + { id: "plugins", label: "Plugins" }, +]; + +function _parseAgentSubroute() { + const raw = (window.location.hash || "").replace(/^#/, ""); + // Support legacy #peers → memory?subsection=peer. + if (raw === "peers" || raw.startsWith("peers/") || raw.startsWith("peers?")) { + window.location.hash = "#agent/memory?subsection=peer"; + return { tab: "memory", subsection: "peer" }; + } + const [path, qs] = raw.split("?"); + const parts = path.split("/"); + if (parts[0] !== "agent") return { tab: "chat", subsection: null }; + const sub = parts[1] || "chat"; + const tab = AGENT_TABS.find(t => t.id === sub) ? sub : "chat"; + let subsection = null; + if (qs) { + for (const kv of qs.split("&")) { + const [k, v = ""] = kv.split("="); + if (k === "subsection") subsection = decodeURIComponent(v); + } + } + return { tab, subsection }; +} + +function AgentView() { + const initial = _parseAgentSubroute(); + const [tab, setTab] = useStateAV(initial.tab); + const [subsection, setSubsection] = useStateAV(initial.subsection); + const [editPersona, setEditPersona] = useStateAV(null); + const [resetOpen, setResetOpen] = useStateAV(false); + const noAgent = window.__hal0Banners && window.__hal0Banners.get && window.__hal0Banners.get()["no-agent"]; + + useEffectAV(() => { + const onHash = () => { + const { tab: t, subsection: s } = _parseAgentSubroute(); + setTab(t); + setSubsection(s); + }; + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + + const goTab = (id) => { + // Preserve top-level #agent route so the App router stays on this + // view; sub-tabs ride in the second segment. + window.location.hash = "#agent/" + id; + }; + + return ( +
+
+ Tools +

Agent

+ + v0.3 · chat composer lands in PR-10 +
+ +
+ {AGENT_TABS.map(t => ( + + ))} +
+ + {tab === "chat" && window.HermesChatTab && } + {tab === "personas" && window.PersonasTab && setEditPersona(p)} />} + {tab === "skills" && window.SkillsTab && } + {tab === "memory" && window.MemoryTab && setResetOpen(true)} />} + {tab === "plugins" && window.PluginsTab && } + + setEditPersona(null)} + /> + setResetOpen(false)} + onConfirm={() => { setResetOpen(false); window.__hal0Toast && window.__hal0Toast("Cognee namespace 'shared' reset — 2,847 records deleted", "warn"); }} + title="Reset memory namespace 'shared'?" + message={This permanently deletes 2,847 Cognee records across SQLite + LanceDB + Kuzu. Cannot be undone.} + confirmLabel="Reset namespace" + destructive + typeToConfirm="shared" + /> +
+ ); +} + +Object.assign(window, { AgentView }); diff --git a/ui/src/dash/agents/hermes-chat-tab.jsx b/ui/src/dash/agents/hermes-chat-tab.jsx new file mode 100644 index 00000000..5e438661 --- /dev/null +++ b/ui/src/dash/agents/hermes-chat-tab.jsx @@ -0,0 +1,58 @@ +// hal0 v0.3 PR-8 — HermesChatTab (placeholder). +// +// The chat composer + transcript lands in PR-10. PR-8 ships a minimal +// placeholder so AgentView has a default tab and the nav doesn't dead-end. +// +// The link points at the activity log (Logs view filtered for hermes) +// so operators have somewhere to go in the interim. + +function HermesChatTab({ noAgent } = {}) { + if (noAgent) { + return ( +
+
+ No bundled agent installed. +
+
+ Run hal0 agent install hermes to bring the chat surface online. +
+
+ ); + } + return ( +
+
+ Hermes · chat +
+
+ Chat surface lands in PR-10. +
+

+ The composer, transcript, persona dropdown, and inline approval + cards arrive with the next PR. Hermes is still running — you can + see its tool calls land in the activity log below. +

+ + {window.Icons && window.Icons.logs} View activity log + +
+ ); +} + +Object.assign(window, { HermesChatTab }); diff --git a/ui/src/dash/agents/memory-tab-hook-bridge.ts b/ui/src/dash/agents/memory-tab-hook-bridge.ts new file mode 100644 index 00000000..e94ff662 --- /dev/null +++ b/ui/src/dash/agents/memory-tab-hook-bridge.ts @@ -0,0 +1,17 @@ +// hal0 dashboard — window-globals bridge for memory-graph hooks. +// +// MemoryTab is a .jsx prototype file (no ES imports across dash/*). +// This bridge republishes the TanStack-Query memory hooks under +// `window.__hal0UseMemoryGraphStatus` + `window.__hal0UseUpdateMemoryGraph` +// so memory-tab.jsx finds them the same way SidebarAgentBlock does. + +import { useMemoryGraphStatus, useUpdateMemoryGraph } from '@/api/hooks/useMemory' + +;(window as unknown as { + __hal0UseMemoryGraphStatus?: typeof useMemoryGraphStatus + __hal0UseUpdateMemoryGraph?: typeof useUpdateMemoryGraph +}).__hal0UseMemoryGraphStatus = useMemoryGraphStatus +;(window as unknown as { + __hal0UseMemoryGraphStatus?: typeof useMemoryGraphStatus + __hal0UseUpdateMemoryGraph?: typeof useUpdateMemoryGraph +}).__hal0UseUpdateMemoryGraph = useUpdateMemoryGraph diff --git a/ui/src/dash/agents/memory-tab.jsx b/ui/src/dash/agents/memory-tab.jsx new file mode 100644 index 00000000..c0870dbb --- /dev/null +++ b/ui/src/dash/agents/memory-tab.jsx @@ -0,0 +1,450 @@ +// hal0 v0.3 PR-8 — MemoryTab. +// +// Composes: +// - GraphExtractionPanel (ADR-0014 — graph build status + route picker) +// - Cognee stats card (records / DB rollup) +// - Recent records card +// - Namespaces side card +// - "Peer memory" subsection (folded in from the old Peers tab) +// +// The Peer memory subsection consumes the live MCP search at +// /api/memory/search (dataset=agents, tag=agent-identity) — the only +// fully-live surface from the original Peers tab. v0.3 keeps it +// read-only per ADR-0011 §2. +// +// `subsection` prop scrolls the page to #peer-memory on mount when set +// (parses #agent/memory?subsection=peer in agent-view.jsx). + +const { useState: useStateMT, useEffect: useEffectMT } = React; + +function MemoryTab({ subsection, onResetNs } = {}) { + useEffectMT(() => { + if (subsection === "peer") { + const el = document.getElementById("peer-memory"); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, [subsection]); + + return ( +
+
+ + +
+
+ Cognee · shared + 2,847 + records · 184 MB + healthy +
+
+ {[ + { l: "SQLite", v: "847", sub: "indexed text" }, + { l: "LanceDB", v: "2,140", sub: "vectors · 768d" }, + { l: "Kuzu", v: "412", sub: "graph edges" }, + ].map((s, i) => ( +
+
{s.l}
+
{s.v}
+
{s.sub}
+
+ ))} +
+
+ +

Recent records

+
+ {[ + { ts: "14:02:11", source: "hermes", kind: "fact", body: "user prefers frozen dataclasses for SlotState types" }, + { ts: "14:00:42", source: "hermes", kind: "convo", body: "session ftr-104 — refactor of slot_manager.py" }, + { ts: "13:58:18", source: "hermes", kind: "code-ref", body: "slot.py:42 — SlotState dataclass with slots=True" }, + { ts: "13:54:01", source: "hermes", kind: "skill-trace", body: "read_file → src/hal0/launchers/slot_manager.py (3 calls)" }, + { ts: "13:50:33", source: "user", kind: "preference", body: "models page: sort installed first" }, + ].map((r, i) => ( +
+
+ {r.ts} + {r.source} + {r.kind} +
+
{r.body}
+
+ ))} +
+ + {/* ── Peer memory (folded in from the old Peers tab) ─────────── */} +
+

Peer memory

+
+
+

+ Agent identity cards published by other hal0 instances (ADR-0011). Cards are immutable; this view is read-only. +

+ +
+ +
+
+
Namespaces
+
+ {[ + { name: "shared", desc: "default · all agents", recs: 2847, active: true }, + { name: "scratch", desc: "ephemeral · auto-prune", recs: 84, active: false }, + { name: "code", desc: "code refs only", recs: 412, active: false }, + ].map(n => ( +
+ +
+
{n.name}
+
{n.desc}
+
+ {n.recs} +
+ ))} + + +
+
+
+
+ ); +} + +// ── ADR-0014 Graph extraction panel ────────────────────────────────── +// Copy MUST match ADR-0014 §3 + §4 verbatim — changes need an ADR amend. +function MemoryGraphPanel() { + const useStatus = window.__hal0UseMemoryGraphStatus; + const useUpdate = window.__hal0UseUpdateMemoryGraph; + const status = useStatus ? useStatus() : { isLoading: false, isError: false, data: null }; + const update = useUpdate ? useUpdate() : { mutate: () => {}, isPending: false }; + const data = status.data; + const enabled = !!(data && data.enabled); + const route = (data && data.route) || "upstream"; + + const [showRoutePicker, setShowRoutePicker] = useStateMT(false); + const [draftRoute, setDraftRoute] = useStateMT(route); + const [draftProvider, setDraftProvider] = useStateMT( + (data && data.upstream && data.upstream.provider) || "openrouter", + ); + const [draftModel, setDraftModel] = useStateMT( + (data && data.upstream && data.upstream.model) || "anthropic/claude-3.5-sonnet", + ); + + const openPanel = () => { + setDraftRoute(route); + setDraftProvider((data && data.upstream && data.upstream.provider) || "openrouter"); + setDraftModel((data && data.upstream && data.upstream.model) || "anthropic/claude-3.5-sonnet"); + setShowRoutePicker(true); + }; + + const submit = () => { + const payload = { enabled: true, route: draftRoute }; + if (draftRoute === "upstream") { + payload.upstream = { provider: draftProvider, model: draftModel }; + } + update.mutate(payload, { + onSuccess: () => { + setShowRoutePicker(false); + window.__hal0Toast && window.__hal0Toast("Graph extraction enabled", "ok"); + }, + onError: (err) => { + window.__hal0Toast && window.__hal0Toast(`Enable failed: ${err.message}`, "err"); + }, + }); + }; + + const disable = () => { + update.mutate({ enabled: false }, { + onSuccess: () => { + window.__hal0Toast && window.__hal0Toast("Graph extraction disabled", "warn"); + }, + }); + }; + + if (status.isLoading) { + return ( +
+
Loading graph extraction status…
+
+ ); + } + if (status.isError) { + return ( +
+
Memory engine unavailable: {String(status.error?.message || "unknown")}
+
+ ); + } + + const errors = (data && data.errors) || 0; + const builds = (data && data.builds_ok) || 0; + const inFlight = (data && data.in_flight) || 0; + const lastBuilt = data && data.last_built_at; + + return ( +
+
+ Graph extraction · ADR-0014 + + {enabled ? ON · {route} : OFF} + +
+ + {!enabled && !showRoutePicker && ( + <> +

+ Graph extraction is off. Memory still stores + searches vectors; the entity/relation graph powering memory_search(mode="graph") isn't built. +

+ + + )} + + {enabled && !showRoutePicker && ( + <> +
+ {[ + { l: "Builds OK", v: String(builds), sub: "lifetime" }, + { l: "Errors", v: String(errors), sub: errors ? "see logs" : "—" }, + { l: "In flight", v: String(inFlight),sub: "pending" }, + ].map((s, i) => ( +
+
{s.l}
+
0 && i === 1 ? "var(--err, #c66)" : "var(--fg)", marginTop: 4}}>{s.v}
+
{s.sub}
+
+ ))} +
+ {lastBuilt && ( +
+ Last build: {lastBuilt} +
+ )} + {data && data.last_error && ( +
+ Last error: {data.last_error} +
+ )} +
+ + +
+ + )} + + {showRoutePicker && ( +
+
+
Route
+ {[ + { id: "upstream", label: "upstream", desc: "Send memory text to a hosted provider per build. Best quality." }, + { id: "primary", label: "primary", desc: "Use the live primary slot. Stays on-box. Quality depends on the model." }, + { id: "agent", label: "agent", desc: "Use the agent slot (NPU). Stays on-box. Latency low; context constrained." }, + ].map(o => ( + + ))} +
+ + {draftRoute === "upstream" && ( +
+ + +
+ )} + + {/* ADR-0014 §3 verbatim privacy disclosure copy. */} +
+
Privacy
+

+ Graph extraction sends ingested memory text to  + {draftRoute === "upstream" ? `${draftProvider} (${draftModel})` : draftRoute === "primary" ? "your primary slot" : "your agent slot"} + {draftRoute === "upstream" ? " for entity + relation extraction. Your raw memory store stays local. Switch to a local slot to keep everything on-box (quality may vary on small models)." : ". This stays on-box. Quality may vary on small models."} +

+
+ + {/* ADR-0014 §4 verbatim quality caveat copy. */} +
+

+ Graph quality varies by model. We don't currently measure it for you — your results may vary. +

+
+ +
+ + +
+
+ )} +
+ ); +} + +// ── PeerMemoryList (folded in from old AgentPeers / #247) ─────────── +// Reads identity cards from the `agents` Cognee dataset via the +// hal0-memory MCP. One card per peer with a TCP-ping reachability dot +// (not stored; pinged on render). Cards immutable per ADR-0011 §2. +function PeerMemoryList() { + const [cards, setCards] = useStateMT([]); + const [loading, setLoading] = useStateMT(true); + const [err, setErr] = useStateMT(null); + + useEffectMT(() => { + let cancelled = false; + (async () => { + try { + // #302: REST shim at /api/memory/search instead of /mcp/memory. + // The streamable-HTTP MCP transport at /mcp/memory/mcp requires + // the initialize handshake — not doable from a fetch() oneshot. + const resp = await fetch("/api/memory/search", { + method: "POST", + headers: { "Content-Type": "application/json", "X-hal0-Agent": "hal0-dashboard" }, + body: JSON.stringify({ + query: "agent identity", + tags: ["agent-identity"], + dataset: "agents", + limit: 50, + }), + }); + const data = await resp.json(); + if (cancelled) return; + const items = (data && data.items) || []; + setCards(items); + setLoading(false); + } catch (e) { + if (!cancelled) { + setErr(String(e)); + setLoading(false); + } + } + })(); + return () => { cancelled = true; }; + }, []); + + if (loading) { + return
Loading peers…
; + } + if (err) { + return
memory MCP unreachable: {err}
; + } + if (!cards.length) { + return ( +
+
No agent identity cards published yet.
+
Cards appear here when a bundled agent finishes hal0 agent bootstrap.
+
+ ); + } + return ( +
+ {cards.map((c, i) => )} +
+ ); +} + +function PeerCard({ card }) { + const md = (card && card.metadata) || {}; + const endpoint = md.endpoint || {}; + const hs = md.hal0_state || {}; + const roles = md.roles || []; + const [reach, setReach] = useStateMT("checking"); + const [expanded, setExpanded] = useStateMT(false); + + useEffectMT(() => { + let cancelled = false; + const url = endpoint.url; + if (!url) { setReach("none"); return; } + const ctrl = new AbortController(); + const tid = setTimeout(() => ctrl.abort(), 5000); + (async () => { + try { + await fetch(url, { method: "HEAD", signal: ctrl.signal, mode: "no-cors" }); + if (!cancelled) setReach("ok"); + } catch (e) { + if (cancelled) return; + setReach(e.name === "AbortError" ? "timeout" : "error"); + } finally { + clearTimeout(tid); + } + })(); + return () => { cancelled = true; ctrl.abort(); }; + }, [endpoint.url]); + + const dotColor = reach === "ok" ? "var(--ok)" : reach === "timeout" ? "var(--warn)" : reach === "error" ? "var(--err)" : "var(--fg-5)"; + + return ( +
+
+ +
{md.display_name || md.agent_id || "(unnamed)"}
+
+
{md.agent_id || "—"}
+ {roles.length > 0 && ( +
+ {roles.map((r, i) => {r})} +
+ )} +
+ endpoint: {endpoint.url || "(none)"}
+ registered: {hs.registered_at || "—"} +
+ + {expanded && ( +
+          {JSON.stringify(md, null, 2)}
+        
+ )} +
+ ); +} + +Object.assign(window, { MemoryTab, PeerMemoryList, PeerCard }); diff --git a/ui/src/dash/agents/personas-tab-hook-bridge.ts b/ui/src/dash/agents/personas-tab-hook-bridge.ts new file mode 100644 index 00000000..75b9f959 --- /dev/null +++ b/ui/src/dash/agents/personas-tab-hook-bridge.ts @@ -0,0 +1,12 @@ +// hal0 dashboard — window-globals bridge for useAgentPersonas. +// +// PersonasTab is a .jsx prototype file (no ES imports across dash/*). +// This bridge republishes the hook as `window.__hal0UseAgentPersonas` +// so the tab component finds it the same way SidebarAgentBlock does. +// +// IMPORTED FROM main.tsx BEFORE extras.jsx + dash/agents/*.jsx evaluate. + +import { useAgentPersonas } from '@/api/hooks/useAgents' + +;(window as unknown as { __hal0UseAgentPersonas?: typeof useAgentPersonas }).__hal0UseAgentPersonas = + useAgentPersonas diff --git a/ui/src/dash/agents/personas-tab.jsx b/ui/src/dash/agents/personas-tab.jsx new file mode 100644 index 00000000..97ea00bb --- /dev/null +++ b/ui/src/dash/agents/personas-tab.jsx @@ -0,0 +1,89 @@ +// hal0 v0.3 PR-8 — PersonasTab. +// +// Reads /api/agents/hermes/personas via the useAgentPersonas hook +// (bridged onto window in personas-tab-hook-bridge.ts). v0.3 is +// read-only: the list renders one card per persona, the active +// persona gets the accent marker, and clicking a card opens the +// existing PersonaEditModal in detail mode. +// +// The "+ custom" card opens PersonaEditModal in create mode (modal +// already supports a null `persona` prop). Modal lives in flow-modals.jsx. + +const { useState: useStatePT } = React; + +function PersonasTab({ onEdit } = {}) { + const usePersonas = window.__hal0UseAgentPersonas; + const personasQuery = usePersonas ? usePersonas("hermes") : { data: null, isLoading: false, isError: false }; + const data = personasQuery.data; + const live = data && Array.isArray(data.personas) ? data.personas : []; + const activeId = data && data.active; + + // Map live API rows onto the prototype card shape. Fall back to a + // static demo trio when the endpoint hasn't seeded yet so the page + // doesn't render empty during onboarding screenshots. + const FALLBACK = [ + { name: "hermes", slot: "primary", model: "qwen3.6-27b-mtp", tone: "operator", desc: "Default — terse, technical, runs skills aggressively. Wired to the dashboard chat surface.", active: true }, + { name: "hermes-coder", slot: "coder", model: "qwen3-coder-30b", tone: "code-focused", desc: "Swaps in when the persona dropdown picks coder. Optimised for refactors and review." }, + { name: "hermes-npu", slot: "agent", model: "gemma3:1b", tone: "low-latency", desc: "NPU coresident · for short follow-ups while keeping voice+embed warm." }, + ]; + const cards = live.length > 0 + ? live.map(p => ({ + name: p.display_name || p.id, + id: p.id, + slot: p.slot || null, + model: p.model || "", + tone: p.tone || "", + desc: p.description || "", + active: !!p.active || (activeId && p.id === activeId), + })) + : FALLBACK; + const addCard = { name: "+ custom", slot: null, model: "", tone: "", desc: "Add a persona — pick a chat slot, set a system prompt, and pick a skill set.", isAdd: true }; + const rows = [...cards, addCard]; + + return ( +
+ {rows.map((p, i) => ( +
+ {p.active &&
} +
+
+ {p.isAdd ? Icons.plus : Icons.agent} +
+
+
{p.name}
+ {p.slot &&
routes to slot {p.slot}{p.model ? " · " + p.model : ""}
} +
+ {p.active && active} +
+ {p.desc &&

{p.desc}

} + {!p.isAdd && ( +
+ {p.tone && {p.tone}} + + + {!p.active && } + +
+ )} + {p.isAdd && ( +
+ +
+ )} +
+ ))} + {personasQuery.isError && ( +
+ /api/agents/hermes/personas unreachable — showing fallback list. +
+ )} +
+ ); +} + +Object.assign(window, { PersonasTab }); diff --git a/ui/src/dash/agents/plugins-tab.jsx b/ui/src/dash/agents/plugins-tab.jsx new file mode 100644 index 00000000..3bcb7b29 --- /dev/null +++ b/ui/src/dash/agents/plugins-tab.jsx @@ -0,0 +1,22 @@ +// hal0 v0.3 PR-8 — PluginsTab. +// +// Thin wrapper around PluginTabHost (PR-7). Lives so AgentView's tab +// switch can render without +// caring about the plugin-host implementation. + +function PluginsTab({ agentId = "hermes" } = {}) { + if (!window.PluginTabHost) { + return ( +
+ Plugin host not available — extras still loading. +
+ ); + } + return ( +
+ +
+ ); +} + +Object.assign(window, { PluginsTab }); diff --git a/ui/src/dash/agents/skills-tab.jsx b/ui/src/dash/agents/skills-tab.jsx new file mode 100644 index 00000000..e5635dcb --- /dev/null +++ b/ui/src/dash/agents/skills-tab.jsx @@ -0,0 +1,56 @@ +// hal0 v0.3 PR-8 — SkillsTab. +// +// Lifted verbatim from the old AgentView monolith (extras.jsx). The +// skill catalog stays static for v0.3 — wiring to /api/agents/skills +// is queued in master plan §4 PR-11 (docs sweep + endpoint catalog). + +function SkillsTab() { + const skills = [ + { name: "read_file", cap: "fs-read", policy: "remember", calls: 247, src: "builtin" }, + { name: "write_file", cap: "fs-write", policy: "always", calls: 38, src: "builtin" }, + { name: "edit_file", cap: "fs-write", policy: "always", calls: 14, src: "builtin" }, + { name: "list_dir", cap: "fs-read", policy: "remember", calls: 41, src: "builtin" }, + { name: "shell_exec", cap: "shell-exec", policy: "always", calls: 9, src: "builtin" }, + { name: "model_pull", cap: "registry-write", policy: "always", calls: 3, src: "hal0-router" }, + { name: "restart_slot", cap: "slot-control", policy: "always", calls: 1, src: "hal0-router" }, + { name: "generate_image", cap: "tool-call", policy: "auto", calls: 18, src: "omnirouter" }, + { name: "transcribe_audio",cap: "tool-call", policy: "auto", calls: 7, src: "omnirouter" }, + { name: "text_to_speech", cap: "tool-call", policy: "auto", calls: 22, src: "omnirouter" }, + { name: "embed_text", cap: "tool-call", policy: "auto", calls: 184, src: "omnirouter" }, + { name: "rerank_documents",cap: "tool-call", policy: "auto", calls: 41, src: "omnirouter" }, + ]; + return ( +
+
+
+ skill + capability + source + policy + calls + +
+ {skills.map(s => ( +
+ {s.name} + {s.cap} + {s.src} + + {s.policy === "always" && always} + {s.policy === "remember" && remember} + {s.policy === "auto" && auto} + {s.policy === "deny" && deny} + + {s.calls} + +
+ ))} +
+
+ 12 skills wired · 8 require approval · 4 auto via OmniRouter · skill source includes builtin, hal0-router, omnirouter, and any user-added MCP servers (none configured). +
+
+ ); +} + +Object.assign(window, { SkillsTab }); diff --git a/ui/src/dash/command-palette.jsx b/ui/src/dash/command-palette.jsx index f8c78d38..c6288f4a 100644 --- a/ui/src/dash/command-palette.jsx +++ b/ui/src/dash/command-palette.jsx @@ -157,7 +157,7 @@ function buildCommandItems() { { id: "r-hardware", route: "hardware", label: "Hardware", icon: Icons.hardware, sub: "cpu, gpu, npu, memory" }, { id: "r-backends", route: "backends", label: "Backends", icon: Icons.backends, sub: "llamacpp, flm, sdcpp, kokoro" }, { id: "r-logs", route: "logs", label: "Logs", icon: Icons.logs, sub: "hal0 + lemond stream", keywords: "tail console output" }, - { id: "r-agent", route: "agent", label: "Agent", icon: Icons.agent, sub: "overview, inbox, skills, memory, personas" }, + { id: "r-agent", route: "agent", label: "Agent", icon: Icons.agent, sub: "chat, personas, skills, memory, plugins" }, { id: "r-settings", route: "settings", label: "Settings", icon: Icons.settings, sub: "auth, secrets, updates, lemond admin" }, { id: "r-firstrun", route: "firstrun", label: "FirstRun picker", icon: Icons.flame, sub: "re-run the bundle picker", keywords: "setup install bundle" }, ]; @@ -228,13 +228,10 @@ function buildCommandItems() { action("a-toggle-tour", "Replay onboarding tour", "the 3-step intro", () => window.dispatchEvent(new CustomEvent("hal0:tour-start"))); - // Approval shortcut - if ((HAL0_DATA.approvals || []).length > 0) { - action("a-approvals", `Review ${HAL0_DATA.approvals.length} pending approval${HAL0_DATA.approvals.length === 1 ? "" : "s"}`, - "agent calls gated awaiting decision", - () => window.dispatchEvent(new CustomEvent("hal0:open-approvals")), - Icons.bell); - } + // v0.3 PR-8: dropped the "Review N pending approvals" shortcut. The + // approvals surface lives in the sidebar pip (PR-6) and inline in the + // chat composer (PR-10). The command palette item used the dead + // HAL0_DATA.approvals fixture. return items; } diff --git a/ui/src/dash/data.jsx b/ui/src/dash/data.jsx index eab877ce..f1c4d8b8 100644 --- a/ui/src/dash/data.jsx +++ b/ui/src/dash/data.jsx @@ -290,11 +290,10 @@ const HAL0_DATA = { { name: "route_to_chat", active: true, target: "agent, primary" }, ], - approvals: [ - { ts: "14:02:09", agent: "pi-coder", tool: "model_pull", arg: "user.Phi-4-Mini" }, - { ts: "14:01:42", agent: "pi-coder", tool: "fs_write", arg: "/scratch/notes/draft.md" }, - { ts: "14:00:18", agent: "pi-coder", tool: "shell_exec", arg: "rg \"TODO\" src/" }, - ], + // v0.3 PR-8: HAL0_DATA.approvals removed. The dashboard reads + // approvals from the live /api/agent/approvals queue (via + // useAgentApprovalsCount in SidebarAgentBlock — PR-6) and renders + // inline cards in the HermesChat composer (PR-10). backends: [ { name: "llamacpp:rocm", kind: "llamacpp", device: "rocm", ver: "v1.0 (b9253)", state: "installed", recommended: true }, diff --git a/ui/src/dash/extras.jsx b/ui/src/dash/extras.jsx index c6f49621..f08f7054 100644 --- a/ui/src/dash/extras.jsx +++ b/ui/src/dash/extras.jsx @@ -1,12 +1,13 @@ -// hal0 dashboard — secondary views: Hardware, Logs, Backends, Agent +// hal0 dashboard — secondary views: Backends, Logs. // -// Phase B1: Hardware / Backends / Logs read from real hooks. AgentView -// stays on HAL0_DATA mock (deferred; follow-up issue tracks Phase B2 +). +// Phase B1: Backends + Logs read from real hooks. v0.3 PR-8 split the +// AgentView monolith out of this file into ui/src/dash/agents/* — see +// agent-view.jsx, hermes-chat-tab.jsx, personas-tab.jsx, skills-tab.jsx, +// memory-tab.jsx, plugins-tab.jsx. import { useBackends } from '@/api/hooks/useBackends' import { useLogsHistorical, useLogsStream } from '@/api/hooks/useLogs' import { useLemondRollup } from '@/api/hooks/useLemonade' -import { useMemoryGraphStatus, useUpdateMemoryGraph } from '@/api/hooks/useMemory' const { useState: useStateX } = React; @@ -377,735 +378,4 @@ function highlightSearch(text, q) { ); } -// ════════════════════════════════════════════════════════════════════ -// AGENT (chat, skills, memory, personas) -// ════════════════════════════════════════════════════════════════════ -function AgentView() { - const [tab, setTab] = useStateX("overview"); - const [editPersona, setEditPersona] = useStateX(null); - const [resetOpen, setResetOpen] = useStateX(false); - const noAgent = window.__hal0Banners && window.__hal0Banners.get && window.__hal0Banners.get()["no-agent"]; - const tabs = [ - { id: "overview", label: "Overview" }, - { id: "inbox", label: "Inbox" }, - { id: "skills", label: "Skills" }, - { id: "memory", label: "Memory" }, - { id: "personas", label: "Personas" }, - { id: "peers", label: "Peers" }, - // v0.3 PR-7: plugin host mounts upstream Hermes plugin tabs - // (kanban today, future plugins free) under a single nav entry. - // PR-8 splits AgentView; this is the minimal edit until then. - { id: "plugins", label: "Plugins" }, - ]; - return ( -
-
- Tools -

Agent

- - scaffolded · v0.2.1 · full surface in v0.3 -
- -
- {tabs.map(t => ( - - ))} -
- - {tab === "overview" && (noAgent ? : )} - {tab === "inbox" && (noAgent ? : )} - {tab === "skills" && } - {tab === "memory" && setResetOpen(true)} />} - {tab === "personas" && setEditPersona(p)} />} - {tab === "peers" && } - {tab === "plugins" && window.PluginTabHost && } - - setEditPersona(null)} - /> - setResetOpen(false)} - onConfirm={() => { setResetOpen(false); window.__hal0Toast && window.__hal0Toast("Cognee namespace 'shared' reset — 2,847 records deleted", "warn"); }} - title="Reset memory namespace 'shared'?" - message={This permanently deletes 2,847 Cognee records across SQLite + LanceDB + Kuzu. Cannot be undone.} - confirmLabel="Reset namespace" - destructive - typeToConfirm="shared" - /> -
- ); -} - -// Empty inbox state (no agent installed) -function EmptyInbox() { - return ( -
-
No pending approvals.
-
Gated tool calls from agents will appear here once an agent is installed.
-
- ); -} - -function AgentOverview() { - return ( -
-
-
-

Bundled agent

-
-
-
-
-
- {Icons.agent} -
-
-
Hermes-Agent
-
service · hal0-agent-hermes.service · running
-
- running · 14d -
-

- Hermes is the resident agent. It exposes a chat surface, executes skills via MCP, writes to Cognee memory, and respects the approval policy from Settings. It runs as a systemd service for v0.2.1; CLI shape (pi-coder) lands in v0.3. -

-
- {[ - { l: "approvals", v: "3", sub: "pending" }, - { l: "skills", v: "12", sub: "wired" }, - { l: "memory", v: "847", sub: "writes" }, - { l: "persona", v: "hermes", sub: "default" }, - ].map((s, i) => ( -
-
{s.l}
-
{s.v}
-
{s.sub}
-
- ))} -
-
- - - -
-
- -
-

Alternative: pi-coderCLI · v0.3

-
-
-
-
@earendil-works/pi-coding-agent
-
CLI shape · 4 tools · invoked per-task · not installed
- -
-
- -
-
-
- Recent activity -
-
- {[ - { ts: "14:02:09", text: "request model_pull user.Phi-4-Mini", st: "pending" }, - { ts: "14:01:42", text: "wrote /scratch/notes/draft.md", st: "approved" }, - { ts: "14:00:18", text: "shell rg \"TODO\" src/", st: "pending" }, - { ts: "13:58:11", text: "wrote cognee record (847)", st: "auto" }, - { ts: "13:54:33", text: "read src/hal0/launchers/", st: "auto" }, - { ts: "13:49:02", text: "denied: shell rm -rf /tmp/cache", st: "denied" }, - ].map((a, i) => ( -
- {a.ts} - {a.text} - - {a.st === "pending" && pending} - {a.st === "approved" && ok} - {a.st === "auto" && auto} - {a.st === "denied" && denied} - -
- ))} -
-
-
-
- ); -} - -function AgentInbox() { - return ( -
-
- {Icons.warn} - - 3 pending approvals. Hermes is paused on these calls until you resolve them. - - policy: registry-write · always -
- {HAL0_DATA.approvals.map((a, i) => ( -
-
- {a.ts} - {a.agent} - requests - {a.tool} - - awaiting approval · 14s - -
-
-
- argument - {a.arg} -
-
- capability - {a.tool.startsWith("model_") ? "registry-write" : a.tool.startsWith("fs_") ? "fs-write" : "shell-exec"} -
-
- reason - {a.tool === "model_pull" ? "user asked Hermes to set up Phi-4-Mini for offline routing" : a.tool === "fs_write" ? "draft notes from current chat session" : "code search across src/"} -
-
-
- - - - -
-
- ))} -
- ); -} - -function AgentSkills() { - const skills = [ - { name: "read_file", cap: "fs-read", policy: "remember", calls: 247, src: "builtin" }, - { name: "write_file", cap: "fs-write", policy: "always", calls: 38, src: "builtin" }, - { name: "edit_file", cap: "fs-write", policy: "always", calls: 14, src: "builtin" }, - { name: "list_dir", cap: "fs-read", policy: "remember", calls: 41, src: "builtin" }, - { name: "shell_exec", cap: "shell-exec", policy: "always", calls: 9, src: "builtin" }, - { name: "model_pull", cap: "registry-write", policy: "always", calls: 3, src: "hal0-router" }, - { name: "restart_slot", cap: "slot-control", policy: "always", calls: 1, src: "hal0-router" }, - { name: "generate_image", cap: "tool-call", policy: "auto", calls: 18, src: "omnirouter" }, - { name: "transcribe_audio",cap: "tool-call", policy: "auto", calls: 7, src: "omnirouter" }, - { name: "text_to_speech", cap: "tool-call", policy: "auto", calls: 22, src: "omnirouter" }, - { name: "embed_text", cap: "tool-call", policy: "auto", calls: 184, src: "omnirouter" }, - { name: "rerank_documents",cap: "tool-call", policy: "auto", calls: 41, src: "omnirouter" }, - ]; - return ( -
-
-
- skill - capability - source - policy - calls - -
- {skills.map(s => ( -
- {s.name} - {s.cap} - {s.src} - - {s.policy === "always" && always} - {s.policy === "remember" && remember} - {s.policy === "auto" && auto} - {s.policy === "deny" && deny} - - {s.calls} - -
- ))} -
-
- 12 skills wired · 8 require approval · 4 auto via OmniRouter · skill source includes builtin, hal0-router, omnirouter, and any user-added MCP servers (none configured). -
-
- ); -} - -function AgentMemory({ onResetNs }) { - return ( -
-
- -
-
- Cognee · shared - 2,847 - records · 184 MB - healthy -
-
- {[ - { l: "SQLite", v: "847", sub: "indexed text" }, - { l: "LanceDB", v: "2,140", sub: "vectors · 768d" }, - { l: "Kuzu", v: "412", sub: "graph edges" }, - ].map((s, i) => ( -
-
{s.l}
-
{s.v}
-
{s.sub}
-
- ))} -
-
- -

Recent records

-
- {[ - { ts: "14:02:11", source: "hermes", kind: "fact", body: "user prefers frozen dataclasses for SlotState types" }, - { ts: "14:00:42", source: "hermes", kind: "convo", body: "session ftr-104 — refactor of slot_manager.py" }, - { ts: "13:58:18", source: "hermes", kind: "code-ref", body: "slot.py:42 — SlotState dataclass with slots=True" }, - { ts: "13:54:01", source: "hermes", kind: "skill-trace", body: "read_file → src/hal0/launchers/slot_manager.py (3 calls)" }, - { ts: "13:50:33", source: "user", kind: "preference", body: "models page: sort installed first" }, - ].map((r, i) => ( -
-
- {r.ts} - {r.source} - {r.kind} -
-
{r.body}
-
- ))} -
-
- -
-
-
Namespaces
-
- {[ - { name: "shared", desc: "default · all agents", recs: 2847, active: true }, - { name: "scratch", desc: "ephemeral · auto-prune", recs: 84, active: false }, - { name: "code", desc: "code refs only", recs: 412, active: false }, - ].map(n => ( -
- -
-
{n.name}
-
{n.desc}
-
- {n.recs} -
- ))} - - -
-
-
-
- ); -} - -// ── ADR-0014 Graph extraction panel ────────────────────────────────── -// One toggle, one route picker, one disclosure, one error counter -// (per ADR-0014 §3 + §4 + §6). Lives inside the Memory tab so the -// rest of the memory surface (namespaces, recent records) renders -// alongside without a route change. -// -// Disclosure copy MUST match ADR-0014 §3 + §4 verbatim — copy changes -// require ADR amendment. -function GraphExtractionPanel() { - const status = useMemoryGraphStatus(); - const update = useUpdateMemoryGraph(); - const data = status.data; - const enabled = !!(data && data.enabled); - const route = (data && data.route) || "upstream"; - - const [showRoutePicker, setShowRoutePicker] = useStateX(false); - const [draftRoute, setDraftRoute] = useStateX(route); - const [draftProvider, setDraftProvider] = useStateX( - (data && data.upstream && data.upstream.provider) || "openrouter", - ); - const [draftModel, setDraftModel] = useStateX( - (data && data.upstream && data.upstream.model) || "anthropic/claude-3.5-sonnet", - ); - - const openPanel = () => { - setDraftRoute(route); - setDraftProvider((data && data.upstream && data.upstream.provider) || "openrouter"); - setDraftModel((data && data.upstream && data.upstream.model) || "anthropic/claude-3.5-sonnet"); - setShowRoutePicker(true); - }; - - const submit = () => { - const payload = { enabled: true, route: draftRoute }; - if (draftRoute === "upstream") { - payload.upstream = { provider: draftProvider, model: draftModel }; - } - update.mutate(payload, { - onSuccess: () => { - setShowRoutePicker(false); - window.__hal0Toast && window.__hal0Toast("Graph extraction enabled", "ok"); - }, - onError: (err) => { - window.__hal0Toast && window.__hal0Toast(`Enable failed: ${err.message}`, "err"); - }, - }); - }; - - const disable = () => { - update.mutate({ enabled: false }, { - onSuccess: () => { - window.__hal0Toast && window.__hal0Toast("Graph extraction disabled", "warn"); - }, - }); - }; - - if (status.isLoading) { - return ( -
-
Loading graph extraction status…
-
- ); - } - if (status.isError) { - return ( -
-
Memory engine unavailable: {String(status.error?.message || "unknown")}
-
- ); - } - - const errors = (data && data.errors) || 0; - const builds = (data && data.builds_ok) || 0; - const inFlight = (data && data.in_flight) || 0; - const lastBuilt = data && data.last_built_at; - - return ( -
-
- Graph extraction · ADR-0014 - - {enabled ? ON · {route} : OFF} - -
- - {!enabled && !showRoutePicker && ( - <> -

- Graph extraction is off. Memory still stores + searches vectors; the entity/relation graph powering memory_search(mode="graph") isn't built. -

- - - )} - - {enabled && !showRoutePicker && ( - <> -
- {[ - { l: "Builds OK", v: String(builds), sub: "lifetime" }, - { l: "Errors", v: String(errors), sub: errors ? "see logs" : "—" }, - { l: "In flight", v: String(inFlight), sub: "pending" }, - ].map((s, i) => ( -
-
{s.l}
-
0 && i === 1 ? "var(--err, #c66)" : "var(--fg)", marginTop: 4}}>{s.v}
-
{s.sub}
-
- ))} -
- {lastBuilt && ( -
- Last build: {lastBuilt} -
- )} - {data && data.last_error && ( -
- Last error: {data.last_error} -
- )} -
- - -
- - )} - - {showRoutePicker && ( -
-
-
Route
- {[ - { id: "upstream", label: "upstream", desc: "Send memory text to a hosted provider per build. Best quality." }, - { id: "primary", label: "primary", desc: "Use the live primary slot. Stays on-box. Quality depends on the model." }, - { id: "agent", label: "agent", desc: "Use the agent slot (NPU). Stays on-box. Latency low; context constrained." }, - ].map(o => ( - - ))} -
- - {draftRoute === "upstream" && ( -
- - -
- )} - - {/* ADR-0014 §3 verbatim privacy disclosure copy. */} -
-
Privacy
-

- Graph extraction sends ingested memory text to  - {draftRoute === "upstream" ? `${draftProvider} (${draftModel})` : draftRoute === "primary" ? "your primary slot" : "your agent slot"} - {draftRoute === "upstream" ? " for entity + relation extraction. Your raw memory store stays local. Switch to a local slot to keep everything on-box (quality may vary on small models)." : ". This stays on-box. Quality may vary on small models."} -

-
- - {/* ADR-0014 §4 verbatim quality caveat copy. */} -
-

- Graph quality varies by model. We don't currently measure it for you — your results may vary. -

-
- -
- - -
-
- )} -
- ); -} - -function AgentPersonas({ onEdit }) { - const personas = [ - { name: "hermes", slot: "primary", model: "qwen3.6-27b-mtp", tone: "operator", desc: "Default — terse, technical, runs skills aggressively. Wired to the dashboard chat surface.", active: true }, - { name: "hermes-coder", slot: "coder", model: "qwen3-coder-30b", tone: "code-focused", desc: "Swaps in when the persona dropdown picks coder. Optimised for refactors and review." }, - { name: "hermes-npu", slot: "agent", model: "gemma3:1b", tone: "low-latency", desc: "NPU coresident · for short follow-ups while keeping voice+embed warm." }, - { name: "+ custom", slot: null, model: "", tone: "", desc: "Add a persona — pick a chat slot, set a system prompt, and pick a skill set.", isAdd: true }, - ]; - return ( -
- {personas.map((p, i) => ( -
- {p.active &&
} -
-
- {p.isAdd ? Icons.plus : Icons.agent} -
-
-
{p.name}
- {p.slot &&
routes to slot {p.slot} · {p.model}
} -
- {p.active && active} -
-

{p.desc}

- {!p.isAdd && ( -
- {p.tone} - - - {!p.active && } - -
- )} - {p.isAdd && ( -
- -
- )} -
- ))} -
- ); -} - -// ── AgentPeers (#247) ─────────────────────────────────────────────────────── -// -// Reads identity cards from the `agents` Cognee dataset (ADR-0011) via -// the hal0-memory MCP. Renders one row per card with a TCP-ping -// reachability dot (no persistent stored field — pinged on render). -// -// Cards are immutable per ADR-0011 §2; this panel is read-only. - -function AgentPeers() { - const [cards, setCards] = useStateX([]); - const [loading, setLoading] = useStateX(true); - const [err, setErr] = useStateX(null); - - React.useEffect(() => { - let cancelled = false; - (async () => { - try { - // #302: REST shim at /api/memory/search instead of /mcp/memory. - // The streamable-HTTP MCP transport at /mcp/memory/mcp requires - // the initialize handshake — not doable from a fetch() oneshot. - const resp = await fetch("/api/memory/search", { - method: "POST", - headers: { "Content-Type": "application/json", "X-hal0-Agent": "hal0-dashboard" }, - body: JSON.stringify({ - query: "agent identity", - tags: ["agent-identity"], - dataset: "agents", - limit: 50, - }), - }); - const data = await resp.json(); - if (cancelled) return; - const items = (data && data.items) || []; - setCards(items); - setLoading(false); - } catch (e) { - if (!cancelled) { - setErr(String(e)); - setLoading(false); - } - } - })(); - return () => { cancelled = true; }; - }, []); - - if (loading) { - return
Loading peers…
; - } - if (err) { - return
memory MCP unreachable: {err}
; - } - if (!cards.length) { - return ( -
-
No agent identity cards published yet.
-
Cards appear here when a bundled agent finishes hal0 agent bootstrap.
-
- ); - } - return ( -
- {cards.map((c, i) => )} -
- ); -} - -function PeerCard({ card }) { - const md = (card && card.metadata) || {}; - const endpoint = md.endpoint || {}; - const hs = md.hal0_state || {}; - const roles = md.roles || []; - const [reach, setReach] = useStateX("checking"); - const [expanded, setExpanded] = useStateX(false); - - React.useEffect(() => { - let cancelled = false; - const url = endpoint.url; - if (!url) { setReach("none"); return; } - const ctrl = new AbortController(); - const tid = setTimeout(() => ctrl.abort(), 5000); - (async () => { - try { - await fetch(url, { method: "HEAD", signal: ctrl.signal, mode: "no-cors" }); - if (!cancelled) setReach("ok"); - } catch (e) { - if (cancelled) return; - setReach(e.name === "AbortError" ? "timeout" : "error"); - } finally { - clearTimeout(tid); - } - })(); - return () => { cancelled = true; ctrl.abort(); }; - }, [endpoint.url]); - - const dotColor = reach === "ok" ? "var(--ok)" : reach === "timeout" ? "var(--warn)" : reach === "error" ? "var(--err)" : "var(--fg-5)"; - - return ( -
-
- -
{md.display_name || md.agent_id || "(unnamed)"}
-
-
{md.agent_id || "—"}
- {roles.length > 0 && ( -
- {roles.map((r, i) => {r})} -
- )} -
- endpoint: {endpoint.url || "(none)"}
- registered: {hs.registered_at || "—"} -
- - {expanded && ( -
-          {JSON.stringify(md, null, 2)}
-        
- )} -
- ); -} - -Object.assign(window, { BackendsView, LogsView, AgentView, AgentPeers, PeerCard }); +Object.assign(window, { BackendsView, LogsView }); diff --git a/ui/src/dash/main.jsx b/ui/src/dash/main.jsx index b995ab63..2e8fbf94 100644 --- a/ui/src/dash/main.jsx +++ b/ui/src/dash/main.jsx @@ -26,6 +26,20 @@ function parseRoute() { head = "mcp"; rest = rest.slice(1); } + // v0.3 PR-8: legacy #peers route redirects to the Peer memory + // subsection inside the Memory tab on the agent route. We mutate + // the hash here so deep links from older docs / bookmarks land on + // the new shape. + if (head === "peers") { + if (typeof window !== "undefined") { + const next = "#agent/memory?subsection=peer"; + if (window.location.hash !== next) { + window.location.hash = next; + } + } + head = "agent"; + rest = ["memory"]; + } const route = ROUTES.includes(head) ? head : "dashboard"; const query = {}; if (qs) { @@ -187,11 +201,16 @@ function App() { return ( <>
+ {/* v0.3 PR-8: approvals are now sourced from the sidebar agent + rollup (PR-6 SidebarAgentBlock + live /api/agent/approvals + poll). The topbar bell stays as a launcher for the modal + view; its badge counter is suppressed until the live hook is + bridged here in PR-10 (it's already alive in the sidebar). */} setBellOpen(true)} onCmdK={() => setPaletteOpen(true)} - approvals={HAL0_DATA.approvals.length} + approvals={0} /> {!isFirstrun && }
@@ -222,10 +241,14 @@ function App() { {!isFirstrun && } + {/* v0.3 PR-8: ApprovalModal stays mounted but its content is + backed by the live agent approvals queue in PR-10. Today the + fixture list is empty — the sidebar pip is the operative + surface (PR-6). */} setBellOpen(false)} - items={HAL0_DATA.approvals} + items={[]} /> setPaletteOpen(false)} /> @@ -318,7 +341,6 @@ function App() { go("firstrun")}>Jump to FirstRun go("dashboard")}>Jump to Dashboard - setBellOpen(true)}>Open approval inbox diff --git a/ui/src/main.tsx b/ui/src/main.tsx index e2c2b73c..80f5e2c9 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -63,6 +63,25 @@ import './dash/agents/plugin-host.jsx' import './dash/extras.jsx' +// v0.3 PR-8: AgentView is now split into per-tab files under +// ui/src/dash/agents/. Bridges install the TanStack-Query hooks onto +// `window.__hal0Use*` BEFORE the .jsx tab modules evaluate, so each +// tab can read them without violating the no-ES-imports contract. +// Load order matters: AgentView (the shell) reads window.{HermesChatTab, +// PersonasTab, SkillsTab, MemoryTab, PluginsTab} at render time, so the +// tabs must register on window before AgentView mounts. AgentView itself +// must register AFTER extras.jsx so its definition wins over any stale +// symbol the old monolith would have left behind (defence-in-depth — +// the old monolith is already removed). +import './dash/agents/personas-tab-hook-bridge' +import './dash/agents/memory-tab-hook-bridge' +import './dash/agents/hermes-chat-tab.jsx' +import './dash/agents/personas-tab.jsx' +import './dash/agents/skills-tab.jsx' +import './dash/agents/memory-tab.jsx' +import './dash/agents/plugins-tab.jsx' +import './dash/agents/agent-view.jsx' + // v0.3 MCP additions — see `hal0 v3 mcp.html` for the original entry. We // pull them into the main SPA so `#mcp` (and the equivalent `#agents/mcp`) // renders the McpView inside the shared chrome rather than a separate page. diff --git a/ui/tests/e2e/specs/agent-v3.spec.ts b/ui/tests/e2e/specs/agent-v3.spec.ts deleted file mode 100644 index 9804ac0d..00000000 --- a/ui/tests/e2e/specs/agent-v3.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * agent-v3 — `#agent` route renders the tabbed view (overview / inbox / - * skills / memory / personas) and the bundled-agent overview card by - * default. - */ -import { test, expect } from '../fixtures/apiMock' - -const TABS = ['overview', 'inbox', 'skills', 'memory', 'personas'] - -test.describe('Agent v3 (/agent)', () => { - test.skip('renders Agent view + all 5 tabs', async ({ page }) => { - await page.goto('/#agent') - await expect(page.locator('.view .vh h1')).toHaveText('Agent') - for (const tab of TABS) { - await expect(page.locator('.view button', { hasText: new RegExp(`^${tab}$`, 'i') })).toBeVisible() - } - }) - - test.skip('default tab is overview — bundled-agent card visible', async ({ page }) => { - await page.goto('/#agent') - await expect(page.locator('.view .sec h2', { hasText: 'Bundled agent' })).toBeVisible() - }) - - test.skip('clicking inbox tab swaps content', async ({ page }) => { - await page.goto('/#agent') - await page.locator('.view button', { hasText: /^inbox$/i }).click() - // inbox state shows either pending approvals list or empty state - const view = page.locator('.view') - await expect(view).toContainText(/No pending approvals|approval/i) - }) -}) diff --git a/ui/tests/e2e/specs/agent-view-v3.spec.ts b/ui/tests/e2e/specs/agent-view-v3.spec.ts new file mode 100644 index 00000000..24e54b67 --- /dev/null +++ b/ui/tests/e2e/specs/agent-view-v3.spec.ts @@ -0,0 +1,94 @@ +/** + * agent-view-v3 — v0.3 PR-8 (master plan §4 PR-8). + * + * Pins the post-refactor AgentView contract: + * - HermesChat is the default tab (placeholder lives here until PR-10) + * - Tab nav covers chat / personas / skills / memory / plugins + * - Inbox and Peers tabs are GONE (Inbox → sidebar pip; Peers → folded + * into Memory as "Peer memory" subsection) + * - Hash routes `#agent/` work; legacy `#peers` redirects to + * `#agent/memory?subsection=peer` + */ +import { test, expect, json } from '../fixtures/apiMock' + +const FIVE_S = 5_500 + +test.describe('AgentView v3 (#agent — PR-8 refactor)', () => { + test.beforeEach(async ({ page }) => { + // Stub the live endpoints the new tabs hit so the spec is hermetic. + await page.route('**/api/agents/hermes/personas', (route) => + json(route, { + agent_id: 'hermes', + active: 'default', + personas: [ + { id: 'default', display_name: 'Hermes', description: 'Default', active: true }, + ], + }), + ) + await page.route('**/api/memory/graph/status', (route) => + json(route, { enabled: false, route: 'upstream' }), + ) + await page.route('**/api/memory/search', (route) => + json(route, { items: [] }), + ) + }) + + test('default tab is chat — placeholder visible, no Inbox or Peers in nav', async ({ page }) => { + await page.goto('/#agent') + await expect(page.locator('[data-testid="agent-tab-nav"]')).toBeVisible({ timeout: FIVE_S }) + + // Default tab content is the HermesChat placeholder. + await expect(page.locator('[data-testid="hermes-chat-placeholder"]')).toBeVisible() + + // Nav contains the new 5-tab set, and ONLY that set. + for (const id of ['chat', 'personas', 'skills', 'memory', 'plugins']) { + await expect(page.locator(`[data-testid="agent-tab-${id}"]`)).toBeVisible() + } + // The dropped tabs MUST NOT be present. + await expect(page.locator('[data-testid="agent-tab-inbox"]')).toHaveCount(0) + await expect(page.locator('[data-testid="agent-tab-peers"]')).toHaveCount(0) + await expect(page.locator('[data-testid="agent-tab-overview"]')).toHaveCount(0) + + // The label text "Inbox" / "Peers" must not appear in the nav. + const nav = page.locator('[data-testid="agent-tab-nav"]') + await expect(nav).not.toContainText('Inbox') + await expect(nav).not.toContainText('Peers') + }) + + test('clicking Personas, Skills, Memory, Plugins swaps content', async ({ page }) => { + await page.goto('/#agent') + + await page.locator('[data-testid="agent-tab-personas"]').click() + await expect(page.locator('[data-testid="personas-tab"]')).toBeVisible({ timeout: FIVE_S }) + + await page.locator('[data-testid="agent-tab-skills"]').click() + await expect(page.locator('[data-testid="skills-tab"]')).toBeVisible() + + await page.locator('[data-testid="agent-tab-memory"]').click() + await expect(page.locator('[data-testid="memory-tab"]')).toBeVisible() + + await page.locator('[data-testid="agent-tab-plugins"]').click() + await expect(page.locator('[data-testid="plugins-tab"]')).toBeVisible() + }) + + test('Memory tab shows the "Peer memory" subsection', async ({ page }) => { + await page.goto('/#agent/memory') + await expect(page.locator('[data-testid="memory-tab"]')).toBeVisible({ timeout: FIVE_S }) + const peer = page.locator('[data-testid="peer-memory-section"]') + await expect(peer).toBeVisible() + await expect(peer).toContainText('Peer memory') + }) + + test('legacy #peers route redirects to #agent/memory?subsection=peer', async ({ page }) => { + await page.goto('/#peers') + // The redirect happens client-side via hashchange handling inside + // agent-view.jsx. Wait for the URL to land on the new shape. + await expect(page).toHaveURL(/#agent\/memory\?subsection=peer/, { timeout: FIVE_S }) + await expect(page.locator('[data-testid="memory-tab"]')).toBeVisible() + }) + + test('#agent/chat hash routes to the chat tab', async ({ page }) => { + await page.goto('/#agent/chat') + await expect(page.locator('[data-testid="hermes-chat-placeholder"]')).toBeVisible({ timeout: FIVE_S }) + }) +})