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 && (
+
+ )}
+