diff --git a/Cargo.lock b/Cargo.lock index 6d1a7d25..6bcb6e0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4536,6 +4536,7 @@ dependencies = [ "tokensave-medium-treesitters", "tokio", "toml", + "tower 0.5.3", "tracing", "tree-sitter", "tree-sitter-hlsl", diff --git a/Cargo.toml b/Cargo.toml index 143ba8b5..3cf44539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ path = "src/main.rs" [dependencies] axum = "0.8" +tower = "0.5" libsql = "0.9.30" tree-sitter = "0.26" tree-sitter-language = "0.1" diff --git a/dashboard/holographic/src/HolographicMemoryPage.tsx b/dashboard/holographic/src/HolographicMemoryPage.tsx index 0676d93f..faddeb0c 100644 --- a/dashboard/holographic/src/HolographicMemoryPage.tsx +++ b/dashboard/holographic/src/HolographicMemoryPage.tsx @@ -333,9 +333,9 @@ function SearchBox({ return (
{refreshing ? ( - + ) : ( - + )} setQuery("")} aria-label="Clear" > diff --git a/dashboard/shell/src/main.jsx b/dashboard/shell/src/main.jsx index ac835660..07dfed78 100644 --- a/dashboard/shell/src/main.jsx +++ b/dashboard/shell/src/main.jsx @@ -13,7 +13,13 @@ import React, { useEffect, useState, useCallback, useRef, useSyncExternalStore } from "react"; import { createRoot } from "react-dom/client"; -import { buildSDK, fetchJSON, cn } from "./sdk.jsx"; +import { + buildSDK, + fetchJSON, + cn, + getSelectedProjectId, + setShellSelectedProjectId, +} from "./sdk.jsx"; // --------------------------------------------------------------------------- // Registry @@ -174,6 +180,14 @@ function injectPluginAssets(manifest) { const POLL_INTERVAL_MS = 15_000; +function basename(path) { + return path?.split("/").filter(Boolean).pop() || path; +} + +function projectLabel(project) { + return project?.label || basename(project?.project_root) || project?.project_id || "Project"; +} + // --------------------------------------------------------------------------- // App // --------------------------------------------------------------------------- @@ -181,6 +195,9 @@ const POLL_INTERVAL_MS = 15_000; function App() { const [plugins, setPlugins] = useState([]); const [capabilities, setCapabilities] = useState(null); + const [projects, setProjects] = useState([]); + const [selectedProjectId, setSelectedProjectId] = useState(""); + const [projectRevision, setProjectRevision] = useState(0); const [active, setActive] = useState(""); // Tabs that have been activated at least once; their panels stay mounted // (hidden) afterwards so in-progress exploration survives tab switches. @@ -244,9 +261,10 @@ function App() { }, []); // Fetch capabilities, update SDK, and return the payload (or null on failure). - const fetchCapabilities = useCallback(async () => { + const fetchCapabilities = useCallback(async (projectId = getSelectedProjectId()) => { try { const caps = await fetchJSON("/api/capabilities"); + if (getSelectedProjectId() !== projectId) return null; setCapabilities(caps); setConnState("ok"); setLastRefresh(Date.now()); @@ -254,21 +272,32 @@ function App() { window.__HERMES_PLUGIN_SDK__.capabilities = caps; return caps; } catch { + if (getSelectedProjectId() !== projectId) return null; + window.__HERMES_PLUGIN_SDK__.capabilities = null; setConnState("error"); return null; } }, []); - // Initial load: plugin list + capabilities in parallel. + // Initial load: plugin manifests are daemon-wide; project inventory selects + // which project-scoped APIs plugin panels should read from. useEffect(() => { let cancelled = false; (async () => { try { - const [list] = await Promise.all([ + const [list, projectPayload] = await Promise.all([ fetchJSON("/api/dashboard/plugins"), - fetchCapabilities(), + fetchJSON("/api/projects"), ]); if (cancelled) return; + const rows = Array.isArray(projectPayload?.projects) ? projectPayload.projects : []; + const initialProjectId = + projectPayload?.active_project_id || rows.find((p) => p.is_active)?.project_id || rows[0]?.project_id || ""; + setProjects(rows); + setSelectedProjectId(initialProjectId); + setShellSelectedProjectId(initialProjectId); + await fetchCapabilities(initialProjectId); + if (cancelled) return; setPlugins(list); if (list.length > 0) { const fromUrl = tabFromUrl(); @@ -290,6 +319,21 @@ function App() { }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + const onProjectChange = useCallback( + async (event) => { + const next = event.target.value; + if (next === selectedProjectId) return; + setSelectedProjectId(next); + setShellSelectedProjectId(next); + setCapabilities(null); + window.__HERMES_PLUGIN_SDK__.capabilities = null; + setProjectRevision((rev) => rev + 1); + setVisited(active ? new Set([active]) : new Set()); + await fetchCapabilities(next); + }, + [active, fetchCapabilities, selectedProjectId], + ); + // Poll capabilities for connection health — but only while the browser tab // is visible; a hidden tab refreshes once on return instead. useEffect(() => { @@ -366,8 +410,12 @@ function App() { const activeManifest = plugins.find((p) => p.name === active); const ActiveComponent = registered.get(active); - const projectName = - capabilities?.project_root?.split("/").filter(Boolean).pop() ?? capabilities?.project_root; + const selectedProject = + projects.find((p) => p.project_id === selectedProjectId) || + projects.find((p) => p.is_active) || + null; + const projectName = selectedProject ? projectLabel(selectedProject) : basename(capabilities?.project_root); + const projectRoot = selectedProject?.project_root || capabilities?.project_root; const connLabel = connState === "ok" @@ -383,7 +431,7 @@ function App() {

tracedecay

{projectName && ( - + {projectName} )} @@ -418,6 +466,22 @@ function App() {
+ {projects.length > 0 && ( + + )} +