Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions dashboard/holographic/src/HolographicMemoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,9 @@ function SearchBox({
return (
<div className="hm-searchbox relative min-w-0 w-full sm:max-w-xl">
{refreshing ? (
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
<Spinner className="text-[0.875rem] text-primary" />
) : (
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Search className="text-muted-foreground" />
)}
<Input
placeholder="Search holographic facts"
Expand All @@ -347,7 +347,7 @@ function SearchBox({
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground"
onClick={() => setQuery("")}
aria-label="Clear"
>
Expand Down
82 changes: 73 additions & 9 deletions dashboard/shell/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -174,13 +180,24 @@ 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
// ---------------------------------------------------------------------------

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.
Expand Down Expand Up @@ -244,31 +261,43 @@ 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());
// Expose fetched capabilities on the SDK so plugin tabs can feature-gate.
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();
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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"
Expand All @@ -383,7 +431,7 @@ function App() {
<span className="ts-shell-logo" aria-hidden="true">◳</span>
<h1 className="ts-shell-title">tracedecay</h1>
{projectName && (
<span className="ts-shell-project" title={capabilities?.project_root}>
<span className="ts-shell-project" title={projectRoot}>
{projectName}
</span>
)}
Expand Down Expand Up @@ -418,6 +466,22 @@ function App() {
</div>

<div className="ts-shell-controls">
{projects.length > 0 && (
<select
className="ts-project-select"
value={selectedProjectId}
onChange={onProjectChange}
aria-label="Project"
title={projectRoot || "Project"}
>
{projects.map((project) => (
<option key={project.project_id} value={project.project_id}>
{projectLabel(project)}
</option>
))}
</select>
)}

<button
className={cn("ts-conn-indicator", `ts-conn-indicator-${connState}`)}
title={connLabel}
Expand Down Expand Up @@ -479,7 +543,7 @@ function App() {
const Component = registered.get(p.name);
return (
<div
key={p.name}
key={`${selectedProjectId || "active"}:${projectRevision}:${p.name}`}
role="tabpanel"
id={`ts-tabpanel-${p.name}`}
aria-labelledby={`ts-tab-${p.name}`}
Expand Down
57 changes: 55 additions & 2 deletions dashboard/shell/src/sdk.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,57 @@ import { cn as cnImpl } from "../../lib/cn";
export { makeSequence };
export const cn = cnImpl;

const projectListeners = new Set();
let selectedProjectId = "";

function notifyProjectListeners() {
for (const fn of projectListeners) {
try {
fn(selectedProjectId);
} catch {
/* listener errors should not break plugin fetches */
}
}
}

function isScopedApiUrl(url, prefix) {
return url === prefix || url.startsWith(`${prefix}/`) || url.startsWith(`${prefix}?`);
}

export function getSelectedProjectId() {
return selectedProjectId;
}

export function setShellSelectedProjectId(projectId) {
const next = String(projectId || "");
if (next === selectedProjectId) return;
selectedProjectId = next;
notifyProjectListeners();
}

export function subscribeSelectedProject(fn) {
projectListeners.add(fn);
return () => projectListeners.delete(fn);
}

export function projectScopedUrl(url, init) {
if (!selectedProjectId || typeof url !== "string" || !url.startsWith("/")) return url;
if (
!isScopedApiUrl(url, "/api/plugins") &&
!isScopedApiUrl(url, "/api/automation") &&
!isScopedApiUrl(url, "/api/capabilities")
) {
return url;
}
return `/api/projects/${encodeURIComponent(selectedProjectId)}${url.slice("/api".length)}`;
}

export function authedFetch(url, init) {
return fetch(projectScopedUrl(url, init), init);
}

export async function fetchJSON(url, init) {
const res = await fetch(url, init);
const res = await authedFetch(url, init);
if (!res.ok) {
let detail = `${res.status} ${res.statusText}`;
let body;
Expand Down Expand Up @@ -171,7 +220,7 @@ export function buildSDK() {
hooks: { useState, useEffect, useCallback, useMemo, useRef, useContext, createContext },
api: {},
fetchJSON,
authedFetch: (url, init) => fetch(url, init),
authedFetch,
buildWsUrl: (p) => p,
buildWsAuthParam: () => ["", ""],
/**
Expand All @@ -182,6 +231,10 @@ export function buildSDK() {
* Null until the first successful fetch.
*/
capabilities: null,
projects: {
getSelectedProjectId,
subscribe: subscribeSelectedProject,
},
components: {
Card,
CardHeader,
Expand Down
38 changes: 37 additions & 1 deletion dashboard/shell/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,34 @@ select:focus-visible {
flex-shrink: 0;
}

.ts-project-select {
appearance: none;
background:
linear-gradient(45deg, transparent 50%, var(--ts-text-3) 50%) calc(100% - 0.82rem) 50% / 5px 5px no-repeat,
linear-gradient(135deg, var(--ts-text-3) 50%, transparent 50%) calc(100% - 0.58rem) 50% / 5px 5px no-repeat,
var(--ts-conn-bg);
border: 1px solid var(--ts-line);
border-radius: 999px;
color: var(--ts-text-2);
cursor: pointer;
font-family: var(--theme-font-mono);
font-size: 0.72rem;
line-height: 1;
max-width: min(22vw, 18rem);
min-width: 10rem;
overflow: hidden;
padding: 0.43rem 1.55rem 0.43rem 0.72rem;
text-overflow: ellipsis;
transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease;
white-space: nowrap;
}

.ts-project-select:hover,
.ts-project-select:focus {
border-color: var(--ts-line-strong);
color: var(--ts-text);
}

/* Connection indicator */
.ts-conn-indicator {
appearance: none;
Expand Down Expand Up @@ -814,7 +842,15 @@ select:focus-visible {
}

.ts-shell-controls {
align-self: flex-end;
align-self: stretch;
flex-wrap: wrap;
justify-content: flex-end;
}

.ts-project-select {
flex: 1 1 12rem;
max-width: none;
min-width: 0;
}

.ts-conn-time {
Expand Down
Loading
Loading