+
-
-
{displayName ?? agentId}
- {activity && (activity.workers > 0 || activity.branches > 0) && (
-
- {activity.workers > 0 && (
-
- {activity.workers}w
-
- )}
- {activity.branches > 0 && (
-
- {activity.branches}b
-
- )}
-
- )}
+
);
}
-export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) {
+export function Sidebar({ liveStates: _liveStates }: SidebarProps) {
const [createOpen, setCreateOpen] = useState(false);
const { data: agentsData } = useQuery({
@@ -119,12 +86,6 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) {
refetchInterval: 30_000,
});
- const { data: channelsData } = useQuery({
- queryKey: ["channels"],
- queryFn: api.channels,
- refetchInterval: 10_000,
- });
-
const { data: providersData } = useQuery({
queryKey: ["providers"],
queryFn: api.providers,
@@ -134,8 +95,7 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) {
const hasProvider = providersData?.has_any ?? false;
const agents = agentsData?.agents ?? [];
- const channels = channelsData?.channels ?? [];
-
+
const agentIds = useMemo(() => agents.map((a) => a.id), [agents]);
const agentDisplayNames = useMemo(() => {
const map: Record
= {};
@@ -153,18 +113,6 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) {
const isOverview = matchRoute({ to: "/" });
const isSettings = matchRoute({ to: "/settings" });
- const agentActivity = useMemo(() => {
- const byAgent: Record = {};
- for (const channel of channels) {
- const live = liveStates[channel.id];
- if (!live) continue;
- if (!byAgent[channel.agent_id]) byAgent[channel.agent_id] = { workers: 0, branches: 0 };
- byAgent[channel.agent_id].workers += Object.keys(live.workers).length;
- byAgent[channel.agent_id].branches += Object.keys(live.branches).length;
- }
- return byAgent;
- }, [channels, liveStates]);
-
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -187,40 +135,9 @@ export function Sidebar({ liveStates, collapsed, onToggle }: SidebarProps) {
};
return (
-
- {/* Logo + collapse toggle */}
-
- {collapsed ? (
-
- ) : (
-
-
-

-
- Spacebot
-
-
-
-
- )}
-
-
- {/* Collapsed: icon-only nav */}
- {collapsed ? (
-
+
{agents[0] && (
)}
-
+
);
}
diff --git a/interface/src/components/TopBar.tsx b/interface/src/components/TopBar.tsx
new file mode 100644
index 000000000..cf5d6c4b0
--- /dev/null
+++ b/interface/src/components/TopBar.tsx
@@ -0,0 +1,118 @@
+import { createContext, useContext, useRef, useCallback, useSyncExternalStore, type ReactNode, type MouseEvent } from "react";
+import { Link } from "@tanstack/react-router";
+import { BASE_PATH, IS_TAURI } from "@/api/client";
+
+// ── Context ──────────────────────────────────────────────────────────────
+
+interface TopBarStore {
+ content: ReactNode;
+ subscribe: (cb: () => void) => () => void;
+ setContent: (node: ReactNode) => void;
+ getSnapshot: () => ReactNode;
+}
+
+function createTopBarStore(): TopBarStore {
+ let content: ReactNode = null;
+ const listeners = new Set<() => void>();
+
+ return {
+ get content() {
+ return content;
+ },
+ subscribe(cb: () => void) {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ setContent(node: ReactNode) {
+ content = node;
+ for (const cb of listeners) cb();
+ },
+ getSnapshot() {
+ return content;
+ },
+ };
+}
+
+const TopBarContext = createContext(null);
+
+export function TopBarProvider({ children }: { children: ReactNode }) {
+ const storeRef = useRef(null);
+ if (!storeRef.current) {
+ storeRef.current = createTopBarStore();
+ }
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Hook for routes to set topbar content. Content is displayed in the TopBar
+ * component rendered at the root layout level.
+ *
+ * The component that calls this hook "owns" the topbar content for its lifetime.
+ * Uses a ref + effect to avoid re-render loops.
+ */
+export function useSetTopBar(node: ReactNode) {
+ const store = useContext(TopBarContext);
+ if (!store) throw new Error("useSetTopBar must be used within TopBarProvider");
+
+ // Update content synchronously during render (like useSyncExternalStore pattern).
+ // This avoids the useEffect loop entirely.
+ store.setContent(node);
+}
+
+// ── Component ────────────────────────────────────────────────────────────
+
+export function TopBar() {
+ const store = useContext(TopBarContext);
+ if (!store) throw new Error("TopBar must be used within TopBarProvider");
+
+ const content = useSyncExternalStore(
+ store.subscribe,
+ store.getSnapshot,
+ store.getSnapshot,
+ );
+
+ const handleMouseDown = useCallback((e: MouseEvent) => {
+ if (!IS_TAURI) return;
+ // Only drag on primary button, and not when clicking interactive elements
+ if (e.buttons !== 1) return;
+ const target = e.target as HTMLElement;
+ if (target.closest("a, button, input, select, textarea, [role=button]")) return;
+ e.preventDefault();
+ (window as any).__TAURI_INTERNALS__.invoke("plugin:window|start_dragging");
+ }, []);
+
+ return (
+
+ {/* Left corner block */}
+ {IS_TAURI ? (
+ /* Tauri: padding for macOS traffic lights */
+
+ ) : (
+ /* Web: ball icon block matching sidebar width + border */
+
+

+
+ )}
+
+ {/* Route-controlled content area */}
+
+ {content}
+
+
+ );
+}
diff --git a/interface/src/main.tsx b/interface/src/main.tsx
index 156771b6f..fb97b5e16 100644
--- a/interface/src/main.tsx
+++ b/interface/src/main.tsx
@@ -7,6 +7,11 @@ import "@fontsource/ibm-plex-sans/500.css";
import "@fontsource/ibm-plex-sans/600.css";
import "@fontsource/ibm-plex-sans/700.css";
+// WKWebView renders at a slightly smaller effective scale than browsers
+if ((window as any).__TAURI_INTERNALS__) {
+ document.body.style.zoom = "1.1";
+}
+
ReactDOM.createRoot(document.getElementById("root")!).render(
diff --git a/interface/src/router.tsx b/interface/src/router.tsx
index 83ff4be04..1914bda3e 100644
--- a/interface/src/router.tsx
+++ b/interface/src/router.tsx
@@ -1,4 +1,3 @@
-import {useState} from "react";
import {
createRouter,
createRootRoute,
@@ -8,7 +7,7 @@ import {
import {useQuery} from "@tanstack/react-query";
import {api, BASE_PATH} from "@/api/client";
import {ConnectionBanner} from "@/components/ConnectionBanner";
-
+import {TopBar, TopBarProvider, useSetTopBar} from "@/components/TopBar";
import {Sidebar} from "@/components/Sidebar";
import {Overview} from "@/routes/Overview";
import {AgentDetail} from "@/routes/AgentDetail";
@@ -28,28 +27,30 @@ import {Settings} from "@/routes/Settings";
import {useLiveContext} from "@/hooks/useLiveContext";
import {AgentTabs} from "@/components/AgentTabs";
+// ── Root layout ──────────────────────────────────────────────────────────
+
function RootLayout() {
const {liveStates, connectionState, hasData} = useLiveContext();
- const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
return (
-
-
setSidebarCollapsed(!sidebarCollapsed)}
- />
-
+
+
+
);
}
-function AgentHeader({agentId}: {agentId: string}) {
+// ── Topbar content for agent routes ──────────────────────────────────────
+
+function AgentTopBar({agentId}: {agentId: string}) {
const agentsQuery = useQuery({
queryKey: ["agents"],
queryFn: () => api.agents(),
@@ -58,9 +59,9 @@ function AgentHeader({agentId}: {agentId: string}) {
const agent = agentsQuery.data?.agents.find((a) => a.id === agentId);
const displayName = agent?.display_name;
- return (
- <>
-