From 61639f5b804367d3691631f841d92cdab17475ea Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 28 May 2026 03:07:02 -0400 Subject: [PATCH 1/2] feat(dash): wire /agent + tabs to real backend (closes #207 #228 #227 #226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces hardcoded mocks in the v3 dashboard's Agent surface with live backend wiring. The v0.3 Agent surface was scaffolded against HAL0_DATA literals; this PR closes the loop on the four cleanup issues that block calling the surface "real". Backend - src/hal0/agents/persona.py — single source-of-truth for persona tones, allowed-tools catalogue, and the agent skills catalogue consumed by the dashboard. Server-side enum so additions don't require UI patches. - src/hal0/api/routes/agents.py — adds GET /api/agents/persona-enums (#226) and GET /api/agents/skills (#227). Read-only veneers over the persona module. - tests/api/test_agents_routes.py — pins both new routes' shape + guard invariants (non-empty, unique ids, operator default present). UI - ui/src/api/hooks/useAgents.ts — useAgents / useAgentSkills / useAgentPersonaEnums TanStack Query hooks. Static catalogues get a generous staleTime; agents list invalidates on mutations. - AgentView (extras.jsx) — drives noAgent state + loading/error banners from useAgents instead of the window.__hal0Banners shim (#207). - AgentSkills — renders rows from useAgentSkills; loading + error states; footer counters computed from the live catalogue (#227). - AgentMemory — top-line "records" + sub-cards switch from the "2,847" / "184 MB" / SQLite / LanceDB / Kuzu literals to the /api/memory/graph/status counters (builds_ok / errors / in_flight) (#228). Per-store breakdown deferred — wrapper doesn't expose it. - PersonaEditModal (flow-modals.jsx) — Tone select + Allowed-tools picker hydrate from useAgentPersonaEnums; no more inline arrays (#226). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/hal0/agents/persona.py | 94 +++++++++++++++++++++++++++ src/hal0/api/routes/agents.py | 36 +++++++++++ tests/api/test_agents_routes.py | 100 +++++++++++++++++++++++++++++ ui/src/api/endpoints.ts | 9 +++ ui/src/api/hooks/index.ts | 1 + ui/src/api/hooks/useAgents.ts | 94 +++++++++++++++++++++++++++ ui/src/dash/extras.jsx | 109 ++++++++++++++++++++++++-------- ui/src/dash/flow-modals.jsx | 34 +++++++--- 8 files changed, 439 insertions(+), 38 deletions(-) create mode 100644 src/hal0/agents/persona.py create mode 100644 tests/api/test_agents_routes.py create mode 100644 ui/src/api/hooks/useAgents.ts diff --git a/src/hal0/agents/persona.py b/src/hal0/agents/persona.py new file mode 100644 index 00000000..fc743152 --- /dev/null +++ b/src/hal0/agents/persona.py @@ -0,0 +1,94 @@ +"""Persona + skill enums consumed by the dashboard's Agent surface. + +The dashboard's PersonaEditModal (#226) renders a Tone select + an +Allowed-tools picker; the Skills tab (#227) renders a catalogue of +agent-callable skills. Both used to hardcode their option lists in +JSX. This module is the single server-side source of truth so the +catalogue can grow without UI patches. + +The enum membership matches what Hermes ships with today (see +``hermes_templates/config.yaml.j2``) plus the OmniRouter capability +ladder the dashboard's #426 inbox already reflects. Adding a new +persona tone or skill capability lands here first. +""" + +from __future__ import annotations + +from typing import TypedDict + + +class PersonaTone(TypedDict): + id: str + label: str + desc: str + + +class PersonaTool(TypedDict): + id: str + label: str + cap: str + + +class AgentSkill(TypedDict): + name: str + cap: str + policy: str + src: str + + +# Persona tone presets shown in PersonaEditModal's Tone select. +# ``id`` is what persists into the persona record; ``label`` + ``desc`` +# are display-only. Keep ``operator`` first — it's the safe default +# baked into the modal's useState seed. +PERSONA_TONES: tuple[PersonaTone, ...] = ( + {"id": "operator", "label": "operator", "desc": "terse + technical"}, + {"id": "code-focused", "label": "code-focused", "desc": "refactors, reviews"}, + {"id": "low-latency", "label": "low-latency", "desc": "NPU coresident"}, + {"id": "vision", "label": "vision-first", "desc": "image-aware"}, + {"id": "conversational", "label": "conversational", "desc": "slower, fuller"}, +) + + +# Allowed-tools picker membership. Mirrors the OmniRouter-callable tool +# set Hermes wires by default (see ``config.yaml.j2`` skills.search). +# ``cap`` is the capability bucket the approval queue gates on. +PERSONA_TOOLS: tuple[PersonaTool, ...] = ( + {"id": "read_file", "label": "read_file", "cap": "fs-read"}, + {"id": "write_file", "label": "write_file", "cap": "fs-write"}, + {"id": "edit_file", "label": "edit_file", "cap": "fs-write"}, + {"id": "shell_exec", "label": "shell_exec", "cap": "shell-exec"}, + {"id": "generate_image", "label": "generate_image", "cap": "tool-call"}, + {"id": "transcribe_audio", "label": "transcribe_audio", "cap": "tool-call"}, + {"id": "text_to_speech", "label": "text_to_speech", "cap": "tool-call"}, + {"id": "embed_text", "label": "embed_text", "cap": "tool-call"}, +) + + +# Skills catalogue rendered in the Agent > Skills tab. Static for v0.3 +# (registry-backed dynamic catalog tracked in #227 follow-up). Policy +# strings line up with the approval queue's enum: ``always`` (gated), +# ``remember`` (gated once + cached), ``auto`` (no gate), ``deny``. +AGENT_SKILLS: tuple[AgentSkill, ...] = ( + {"name": "read_file", "cap": "fs-read", "policy": "remember", "src": "builtin"}, + {"name": "write_file", "cap": "fs-write", "policy": "always", "src": "builtin"}, + {"name": "edit_file", "cap": "fs-write", "policy": "always", "src": "builtin"}, + {"name": "list_dir", "cap": "fs-read", "policy": "remember", "src": "builtin"}, + {"name": "shell_exec", "cap": "shell-exec", "policy": "always", "src": "builtin"}, + {"name": "model_pull", "cap": "registry-write", "policy": "always", "src": "hal0-router"}, + {"name": "restart_slot", "cap": "slot-control", "policy": "always", "src": "hal0-router"}, + {"name": "generate_image", "cap": "tool-call", "policy": "auto", "src": "omnirouter"}, + {"name": "transcribe_audio", "cap": "tool-call", "policy": "auto", "src": "omnirouter"}, + {"name": "text_to_speech", "cap": "tool-call", "policy": "auto", "src": "omnirouter"}, + {"name": "embed_text", "cap": "tool-call", "policy": "auto", "src": "omnirouter"}, + {"name": "rerank_documents", "cap": "tool-call", "policy": "auto", "src": "omnirouter"}, +) + + +__all__ = [ + "AGENT_SKILLS", + "PERSONA_TONES", + "PERSONA_TOOLS", + "AgentSkill", + "PersonaTone", + "PersonaTool", +] diff --git a/src/hal0/api/routes/agents.py b/src/hal0/api/routes/agents.py index baab5b48..939b205b 100644 --- a/src/hal0/api/routes/agents.py +++ b/src/hal0/api/routes/agents.py @@ -32,6 +32,7 @@ HermesNotHal0AwareError, ) from hal0.agents.manager import BUNDLED_AGENTS +from hal0.agents.persona import AGENT_SKILLS, PERSONA_TONES, PERSONA_TOOLS from hal0.errors import BadRequest, Conflict, Hal0Error, NotFound router = APIRouter() @@ -55,6 +56,41 @@ async def list_agents() -> dict[str, object]: return {"agents": items, "count": len(items)} +# ── GET /api/agents/persona-enums ──────────────────────────────────────────── + + +@router.get("/persona-enums") +async def persona_enums() -> dict[str, object]: + """Enum payload for PersonaEditModal (#226). + + Returns the canonical tone presets + the allowed-tools catalogue + the modal renders. Tones + tools live in + :mod:`hal0.agents.persona`; adding new entries lands there. + """ + return { + "tones": list(PERSONA_TONES), + "tools": list(PERSONA_TOOLS), + } + + +# ── GET /api/agents/skills ─────────────────────────────────────────────────── + + +@router.get("/skills") +async def list_skills() -> dict[str, object]: + """Catalogue for the dashboard's Agent → Skills tab (#227). + + Static for v0.3 — sources from :data:`hal0.agents.persona.AGENT_SKILLS`. + The ``calls`` column is omitted at the API layer; the dashboard + shows zero counts until #227's follow-up wires the journal-derived + counters. + """ + return { + "skills": list(AGENT_SKILLS), + "count": len(AGENT_SKILLS), + } + + # ── POST /api/agents/install ────────────────────────────────────────────────── diff --git a/tests/api/test_agents_routes.py b/tests/api/test_agents_routes.py new file mode 100644 index 00000000..2eb9db37 --- /dev/null +++ b/tests/api/test_agents_routes.py @@ -0,0 +1,100 @@ +"""Integration tests for ``/api/agents/*`` enum + skills surface. + +Covers the two read-only catalogues the dashboard's Agent surface +consumes: + + - ``GET /api/agents/persona-enums`` — PersonaEditModal (#226) + - ``GET /api/agents/skills`` — Agent > Skills tab (#227) + +Both routes are stateless veneers over :mod:`hal0.agents.persona`, so +the test pins the response shape + a couple of guard-rail invariants +(non-empty payload, no duplicate ids) rather than the full enum +membership — that lives in the module itself and would just churn +the test on every catalogue add. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hal0.api.middleware import error_codes +from hal0.api.routes import agents as agents_routes + + +def _build_app() -> FastAPI: + app = FastAPI() + error_codes.install(app) + app.include_router(agents_routes.router, prefix="/api/agents", tags=["agents"]) + return app + + +@pytest.fixture +def client() -> Iterator[TestClient]: + with TestClient(_build_app()) as c: + yield c + + +# ── /api/agents/persona-enums ──────────────────────────────────────────── + + +def test_persona_enums_shape(client: TestClient) -> None: + res = client.get("/api/agents/persona-enums") + assert res.status_code == 200 + body = res.json() + assert set(body.keys()) == {"tones", "tools"} + + tones = body["tones"] + tools = body["tools"] + assert isinstance(tones, list) and tones, "tones must be non-empty" + assert isinstance(tools, list) and tools, "tools must be non-empty" + + # Every entry carries id/label; tones have desc, tools have cap. + for t in tones: + assert {"id", "label", "desc"} <= set(t.keys()) + assert t["id"] and t["label"] + for t in tools: + assert {"id", "label", "cap"} <= set(t.keys()) + assert t["id"] and t["label"] and t["cap"] + + +def test_persona_enums_ids_unique(client: TestClient) -> None: + body = client.get("/api/agents/persona-enums").json() + tone_ids = [t["id"] for t in body["tones"]] + tool_ids = [t["id"] for t in body["tools"]] + assert len(tone_ids) == len(set(tone_ids)), "duplicate tone ids" + assert len(tool_ids) == len(set(tool_ids)), "duplicate tool ids" + + +def test_persona_enums_operator_default_present(client: TestClient) -> None: + # The modal seeds tone="operator" — guard so we don't accidentally + # rename it out from under the UI. + tone_ids = [t["id"] for t in client.get("/api/agents/persona-enums").json()["tones"]] + assert "operator" in tone_ids + + +# ── /api/agents/skills ─────────────────────────────────────────────────── + + +def test_skills_catalog_shape(client: TestClient) -> None: + res = client.get("/api/agents/skills") + assert res.status_code == 200 + body = res.json() + assert set(body.keys()) == {"skills", "count"} + skills = body["skills"] + assert isinstance(skills, list) and skills + assert body["count"] == len(skills) + + expected_keys = {"name", "cap", "policy", "src"} + for s in skills: + assert expected_keys <= set(s.keys()) + assert s["policy"] in {"always", "remember", "auto", "deny"} + + +def test_skills_catalog_names_unique(client: TestClient) -> None: + skills = client.get("/api/agents/skills").json()["skills"] + names = [s["name"] for s in skills] + assert len(names) == len(set(names)), "duplicate skill names" diff --git a/ui/src/api/endpoints.ts b/ui/src/api/endpoints.ts index 564ef76e..e9616753 100644 --- a/ui/src/api/endpoints.ts +++ b/ui/src/api/endpoints.ts @@ -56,6 +56,15 @@ export const ENDPOINTS = { // ── Hardware ───────────────────────────────────────────────────── hardware: '/api/hardware', + // ── Agents — list + dashboard catalogues ───────────────────────── + // ``agents`` is the installed-bundled list (#207). ``agentSkills`` + + // ``agentPersonaEnums`` back the Skills tab (#227) + the + // PersonaEditModal selects (#226). Static catalogues sourced from + // ``hal0.agents.persona`` server-side. + agents: '/api/agents', + agentSkills: '/api/agents/skills', + agentPersonaEnums: '/api/agents/persona-enums', + // ── Agents — MCP-client allow-list (ADR-0013) ──────────────────── agentMcpClients: '/api/agents/mcp/clients', agentMcpClient: (name: string) => diff --git a/ui/src/api/hooks/index.ts b/ui/src/api/hooks/index.ts index 259d0d2f..591f7eed 100644 --- a/ui/src/api/hooks/index.ts +++ b/ui/src/api/hooks/index.ts @@ -14,6 +14,7 @@ export * from './useLogs' export * from './useUpdates' export * from './useSecrets' export * from './useFirstRun' +export * from './useAgents' export * from './useAgentMcpClients' export * from './useMcp' export * from './useMemory' diff --git a/ui/src/api/hooks/useAgents.ts b/ui/src/api/hooks/useAgents.ts new file mode 100644 index 00000000..fde802a0 --- /dev/null +++ b/ui/src/api/hooks/useAgents.ts @@ -0,0 +1,94 @@ +// hal0 v3 dashboard — bundled-agent + persona/skills catalogues. +// +// Backs the Agent surface (#207, #226, #227). Three read-only queries: +// +// - useAgents() → GET /api/agents +// - useAgentSkills() → GET /api/agents/skills +// - useAgentPersonaEnums() → GET /api/agents/persona-enums +// +// All catalogues are static for v0.3; ``staleTime`` is generous so the +// dashboard doesn't refetch on every tab switch. Re-fetch on +// invalidate (install/uninstall mutations) handles the dynamic case +// for the list endpoint. +// +// Note: the per-agent activity endpoint already lives in this surface +// (/api/agents/{name}/activity) but the Agent Inbox tab still reads +// from HAL0_DATA mock — wiring that lives in a separate follow-up. + +import { useQuery } from '@tanstack/react-query' +import { apiGet } from '../client' +import { ENDPOINTS } from '../endpoints' + +// ── /api/agents ────────────────────────────────────────────────────── + +export interface AgentRecord { + name: string + shape: string + state: string + installed_at?: string | null + // Manager.as_dict() may attach more keys (driver paths, etc); the + // dashboard only cares about identity + state for v0.3. + [key: string]: unknown +} + +export interface AgentsResponse { + agents: AgentRecord[] + count: number +} + +export function useAgents() { + return useQuery({ + queryKey: ['agents', 'list'], + queryFn: () => apiGet(ENDPOINTS.agents), + staleTime: 30_000, + }) +} + +// ── /api/agents/skills ─────────────────────────────────────────────── + +export interface AgentSkill { + name: string + cap: string + policy: 'always' | 'remember' | 'auto' | 'deny' + src: string +} + +export interface AgentSkillsResponse { + skills: AgentSkill[] + count: number +} + +export function useAgentSkills() { + return useQuery({ + queryKey: ['agents', 'skills'], + queryFn: () => apiGet(ENDPOINTS.agentSkills), + staleTime: 5 * 60_000, + }) +} + +// ── /api/agents/persona-enums ──────────────────────────────────────── + +export interface PersonaTone { + id: string + label: string + desc: string +} + +export interface PersonaTool { + id: string + label: string + cap: string +} + +export interface PersonaEnumsResponse { + tones: PersonaTone[] + tools: PersonaTool[] +} + +export function useAgentPersonaEnums() { + return useQuery({ + queryKey: ['agents', 'persona-enums'], + queryFn: () => apiGet(ENDPOINTS.agentPersonaEnums), + staleTime: 5 * 60_000, + }) +} diff --git a/ui/src/dash/extras.jsx b/ui/src/dash/extras.jsx index 2ec67d7b..da83e56a 100644 --- a/ui/src/dash/extras.jsx +++ b/ui/src/dash/extras.jsx @@ -7,6 +7,7 @@ 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' +import { useAgents, useAgentSkills } from '@/api/hooks/useAgents' const { useState: useStateX } = React; @@ -384,7 +385,16 @@ 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"]; + // #207: drive overview/inbox empty-state from /api/agents instead of + // the legacy banner mock. ``noAgent`` is true when the query has + // resolved and reports zero installed agents; loading + error paths + // fall through to the views which render their own placeholders. + const agentsQuery = useAgents(); + const agentCount = agentsQuery.data?.count ?? null; + const noAgent = + agentsQuery.isSuccess + ? agentCount === 0 + : !!(window.__hal0Banners && window.__hal0Banners.get && window.__hal0Banners.get()["no-agent"]); const tabs = [ { id: "overview", label: "Overview" }, { id: "inbox", label: "Inbox" }, @@ -422,6 +432,19 @@ function AgentView() { ))} + {agentsQuery.isLoading && ( +
+
Loading agents…
+
+ )} + {agentsQuery.isError && ( +
+
+ Agent surface unavailable: {String(agentsQuery.error?.message || "unknown")} +
+
+ )} + {tab === "overview" && (noAgent ? : )} {tab === "inbox" && (noAgent ? : )} {tab === "skills" && } @@ -437,9 +460,9 @@ function AgentView() { setResetOpen(false)} - onConfirm={() => { setResetOpen(false); window.__hal0Toast && window.__hal0Toast("Cognee namespace 'shared' reset — 2,847 records deleted", "warn"); }} + onConfirm={() => { setResetOpen(false); window.__hal0Toast && window.__hal0Toast("Cognee namespace 'shared' reset", "warn"); }} title="Reset memory namespace 'shared'?" - message={This permanently deletes 2,847 Cognee records across SQLite + LanceDB + Kuzu. Cannot be undone.} + message={This permanently deletes all Cognee records in the shared namespace across SQLite + LanceDB + Kuzu. Cannot be undone.} confirmLabel="Reset namespace" destructive typeToConfirm="shared" @@ -592,33 +615,44 @@ function AgentInbox() { } 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" }, - ]; + // #227: catalogue fetched from /api/agents/skills. Static for v0.3; + // the journal-derived calls column lands in a follow-up. + const skillsQuery = useAgentSkills(); + const skills = skillsQuery.data?.skills ?? []; + + if (skillsQuery.isLoading) { + return ( +
+
Loading skills…
+
+ ); + } + if (skillsQuery.isError) { + return ( +
+
+ Skills unavailable: {String(skillsQuery.error?.message || "unknown")} +
+
+ ); + } + + const gated = skills.filter(s => s.policy === "always" || s.policy === "remember").length; + const auto = skills.filter(s => s.policy === "auto").length; + const sources = Array.from(new Set(skills.map(s => s.src))).join(", "); + return (
-
+
skill capability source policy - calls
{skills.map(s => ( -
+
{s.name} {s.cap} {s.src} @@ -628,19 +662,30 @@ function AgentSkills() { {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). + {skills.length} skills wired · {gated} require approval · {auto} auto · sources: {sources || "—"}.
); } function AgentMemory({ onResetNs }) { + // #228: counters sourced from /api/memory/graph/status. The original + // SQLite / LanceDB / Kuzu per-store breakdown isn't exposed by the + // status endpoint — those rows render "—" until the wrapper grows a + // per-backend counter (tracked in follow-up). Top-level record count + // == ``builds_ok`` (one record per successful build) and the engine + // health chip flips based on the error counter. + const memStatus = useMemoryGraphStatus(); + const memData = memStatus.data; + const builds = memData?.builds_ok ?? null; + const errors = memData?.errors ?? null; + const healthy = memStatus.isSuccess && (errors ?? 0) === 0; + return (
@@ -648,15 +693,23 @@ function AgentMemory({ onResetNs }) {
Cognee · shared - 2,847 - records · 184 MB - healthy + + {memStatus.isLoading ? "…" : builds != null ? builds.toLocaleString() : "—"} + + + records{errors != null && errors > 0 ? ` · ${errors} errors` : ""} + + + {memStatus.isLoading && loading} + {memStatus.isError && unavailable} + {memStatus.isSuccess && (healthy ? healthy : degraded)} +
{[ - { l: "SQLite", v: "847", sub: "indexed text" }, - { l: "LanceDB", v: "2,140", sub: "vectors · 768d" }, - { l: "Kuzu", v: "412", sub: "graph edges" }, + { l: "Builds OK", v: builds != null ? String(builds) : "—", sub: "lifetime" }, + { l: "Errors", v: errors != null ? String(errors) : "—", sub: errors ? "see logs" : "—" }, + { l: "In flight", v: memData?.in_flight != null ? String(memData.in_flight) : "—", sub: "pending" }, ].map((s, i) => (
{s.l}
diff --git a/ui/src/dash/flow-modals.jsx b/ui/src/dash/flow-modals.jsx index 8d7addfc..c28d4fbd 100644 --- a/ui/src/dash/flow-modals.jsx +++ b/ui/src/dash/flow-modals.jsx @@ -1,6 +1,8 @@ // hal0 dashboard — FirstRun + Backends + Agent flow modals // Skip confirm, post-install hero, backend install/uninstall, FLM .deb guide, persona edit, namespace reset +import { useAgentPersonaEnums } from '@/api/hooks/useAgents' + const { useState: useStateFM, useEffect: useEffectFM } = React; // ──────────────────────────────────────────────────────────────── @@ -178,6 +180,12 @@ function PersonaEditModal({ open, onClose, persona }) { const [tone, setTone] = useStateFM(persona?.tone || "operator"); const [slot, setSlot] = useStateFM(persona?.slot || "primary"); + // #226: tone + tool catalogues come from /api/agents/persona-enums + // so the picker tracks the server-side enum without UI patches. + const enums = useAgentPersonaEnums(); + const tones = enums.data?.tones ?? []; + const tools = enums.data?.tools ?? []; + useEffectFM(() => { if (open && persona) { setSlot(persona.slot || "primary"); @@ -234,12 +242,12 @@ function PersonaEditModal({ open, onClose, persona }) { descriptive label · doesn't affect routing
- setTone(e.target.value)} disabled={enums.isLoading}> + {enums.isLoading && } + {enums.isError && } + {tones.map(t => ( + + ))}
@@ -268,10 +276,16 @@ function PersonaEditModal({ open, onClose, persona }) { subset of OmniRouter tools this persona can call
- {["read_file", "write_file", "edit_file", "shell_exec", "generate_image", "transcribe_audio", "text_to_speech", "embed_text"].map(t => ( -
From 0584d0e6bc435e3ce7e7bd1294c7a92dabb273e7 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 28 May 2026 03:27:52 -0400 Subject: [PATCH 2/2] polish(dash): guard PersonaEditModal enum fetch + empty-tones fallback Addresses PR #364 reviewer mediums: - useAgentPersonaEnums now accepts { enabled } and PersonaEditModal passes enabled: open so the catalogue isn't fetched while the modal is closed (mirrors useSlotDetail's enabled: !!name pattern). - setTone(e.target.value)} disabled={enums.isLoading}> {enums.isLoading && } {enums.isError && } + {!enums.isLoading && !enums.isError && tones.length === 0 && ( + + )} {tones.map(t => ( ))}