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 => (
+ goTab(t.id)}
+ style={{
+ padding: "10px 16px",
+ background: "transparent",
+ border: "none",
+ borderBottom: tab === t.id ? "2px solid var(--accent)" : "2px solid transparent",
+ color: tab === t.id ? "var(--accent)" : "var(--fg-3)",
+ fontFamily: "var(--jbm)",
+ fontSize: 12.5,
+ cursor: "pointer",
+ fontWeight: 500,
+ }}
+ >{t.label}
+ ))}
+
+
+ {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}
+
+ ))}
+
+
+
+
+
+ {[
+ { 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) ─────────── */}
+
+
+ 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 => (
+
+ ))}
+
window.__hal0Toast && window.__hal0Toast("New namespace modal — stubbed", "info")}>{Icons.plus} New namespace
+
{Icons.warn} Reset 'shared'
+
+
+
+
+ );
+}
+
+// ── 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.
+
+
+ Enable graph extraction
+
+ >
+ )}
+
+ {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}
+
+ )}
+
+ {Icons.edit} Change route
+ Disable
+
+ >
+ )}
+
+ {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 => (
+
+ setDraftRoute(o.id)}
+ />
+
+ {o.label}
+ {o.desc}
+
+
+ ))}
+
+
+ {draftRoute === "upstream" && (
+
+
+ Provider
+ setDraftProvider(e.target.value)}
+ style={{width: "100%", padding: "6px 8px", background: "var(--bg-2)", border: "1px solid var(--line)", color: "var(--fg)", fontFamily: "var(--jbm)", fontSize: 12}}
+ >
+ openrouter
+ anthropic
+ openai
+ custom
+
+
+
+ Model
+ setDraftModel(e.target.value)}
+ placeholder="anthropic/claude-3.5-sonnet"
+ style={{width: "100%", padding: "6px 8px", background: "var(--bg-2)", border: "1px solid var(--line)", color: "var(--fg)", fontFamily: "var(--jbm)", fontSize: 12}}
+ />
+
+
+ )}
+
+ {/* 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.
+
+
+
+
+ setShowRoutePicker(false)}>Cancel
+
+ {update.isPending ? "Saving…" : enabled ? "Save route" : "Enable graph extraction"}
+
+
+
+ )}
+
+ );
+}
+
+// ── 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 || "—"}
+
+
setExpanded(e => !e)}
+ className="mono"
+ style={{
+ marginTop: 4,
+ padding: "4px 8px",
+ fontSize: 10,
+ background: "transparent",
+ border: "1px solid var(--line)",
+ borderRadius: 4,
+ color: "var(--fg-3)",
+ cursor: "pointer",
+ alignSelf: "flex-start",
+ }}
+ >{expanded ? "hide" : "show"} metadata
+ {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} }
+
+ onEdit && onEdit(p)}>{Icons.edit} Edit
+ {!p.active && window.__hal0Toast && window.__hal0Toast(`Persona ${p.name} activated`, "ok")}>Activate }
+
+
+ )}
+ {p.isAdd && (
+
+ onEdit && onEdit(p)}>{Icons.plus} Create persona
+
+ )}
+
+ ))}
+ {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}
+ {Icons.edit}
+
+ ))}
+
+
+ 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 => (
- setTab(t.id)}
- style={{
- padding: "10px 16px",
- background: "transparent",
- border: "none",
- borderBottom: tab === t.id ? "2px solid var(--accent)" : "2px solid transparent",
- color: tab === t.id ? "var(--accent)" : "var(--fg-3)",
- fontFamily: "var(--jbm)",
- fontSize: 12.5,
- cursor: "pointer",
- fontWeight: 500,
- }}
- >{t.label}
- ))}
-
-
- {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 (
-
-
-
-
-
-
- {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}
-
- ))}
-
-
- {Icons.send} Open chat with Hermes
- {Icons.logs} View activity
- {Icons.restart} Restart service
-
-
-
-
-
Alternative: pi-coderCLI · v0.3
-
-
-
-
@earendil-works/pi-coding-agent
-
CLI shape · 4 tools · invoked per-task · not installed
-
Install pi-coder (v0.3)
-
-
-
-
-
-
- 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/"}
-
-
-
- Deny
- Deny + remember
- Approve once
- Approve + remember
-
-
- ))}
-
- );
-}
-
-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}
- {Icons.edit}
-
- ))}
-
-
- 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}
-
- ))}
-
-
-
-
-
- {[
- { 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 => (
-
- ))}
-
window.__hal0Toast && window.__hal0Toast("New namespace modal — stubbed", "info")}>{Icons.plus} New namespace
-
{Icons.warn} Reset 'shared'
-
-
-
-
- );
-}
-
-// ── 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.
-
-
- Enable graph extraction
-
- >
- )}
-
- {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}
-
- )}
-
- {Icons.edit} Change route
- Disable
-
- >
- )}
-
- {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 => (
-
- setDraftRoute(o.id)}
- />
-
- {o.label}
- {o.desc}
-
-
- ))}
-
-
- {draftRoute === "upstream" && (
-
-
- Provider
- setDraftProvider(e.target.value)}
- style={{width: "100%", padding: "6px 8px", background: "var(--bg-2)", border: "1px solid var(--line)", color: "var(--fg)", fontFamily: "var(--jbm)", fontSize: 12}}
- >
- openrouter
- anthropic
- openai
- custom
-
-
-
- Model
- setDraftModel(e.target.value)}
- placeholder="anthropic/claude-3.5-sonnet"
- style={{width: "100%", padding: "6px 8px", background: "var(--bg-2)", border: "1px solid var(--line)", color: "var(--fg)", fontFamily: "var(--jbm)", fontSize: 12}}
- />
-
-
- )}
-
- {/* 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.
-
-
-
-
- setShowRoutePicker(false)}>Cancel
-
- {update.isPending ? "Saving…" : enabled ? "Save route" : "Enable graph extraction"}
-
-
-
- )}
-
- );
-}
-
-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}
-
- onEdit && onEdit(p)}>{Icons.edit} Edit
- {!p.active && window.__hal0Toast && window.__hal0Toast(`Persona ${p.name} activated`, "ok")}>Activate }
-
-
- )}
- {p.isAdd && (
-
- onEdit && onEdit(p)}>{Icons.plus} Create persona
-
- )}
-
- ))}
-
- );
-}
-
-// ── 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 || "—"}
-
-
setExpanded(e => !e)}
- className="mono"
- style={{
- marginTop: 4,
- padding: "4px 8px",
- fontSize: 10,
- background: "transparent",
- border: "1px solid var(--line)",
- borderRadius: 4,
- color: "var(--fg-3)",
- cursor: "pointer",
- alignSelf: "flex-start",
- }}
- >{expanded ? "hide" : "show"} metadata
- {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 })
+ })
+})