diff --git a/CHANGELOG.md b/CHANGELOG.md
index 304164c..ff98961 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,46 @@
Human-curated release notes. Earlier 0.9.x notes also live on the
[GitHub releases](https://github.com/benjsmith/switchbay/releases) page.
+## 2026-10-08 — v0.14.0
+
+**Migration:** none. Your settings carry over. **Breaking:** the Graph tab is
+now always Curiosity Engine's own viewer; Switch Bay's separate copy of it is
+gone.
+
+Works with curiosity-engine 1.9.2, curiosity-merge 0.8.4, and okstratr 0.2.0.
+
+### Changed
+
+- **Graph is Curiosity Engine's viewer** — the Graph tab, the wiki page list
+ in the left column, and the graph in Zen all show Curiosity Engine's own
+ viewer. Search, type filters, Atlas and Classic views, keyboard shortcuts,
+ the page window, note editing, curation replay and splitting a workspace
+ all come from Curiosity Engine, so they match what you see in its own
+ viewer and pick up its improvements when you update it.
+- **Switch Bay extras inside the CE viewer** — pages still render maths,
+ tables still open as a Sheet or Plot, pages can still start a slideshow,
+ Procedures and Executions have their own label colours, and the + button
+ still opens Switch Bay's upload dialog.
+- **Without Curiosity Engine** — the Graph tab is hidden and links that would
+ open it go to the Editor instead. Files, uploading to the vault, RAG,
+ Agents and Library work as before. A workspace with no wiki yet shows a
+ short note in the Graph tab instead of an empty canvas.
+- **Settings → "Agents: okstratr observer"** — the old "Proxied skill
+ embeds" switch now only chooses between the okstratr observer and the
+ built-in Agents dashboard. It no longer changes the Graph tab. Your
+ existing choice is kept.
+
+### Fixed
+
+- **Safer page window** — page content shown in the Graph page window is
+ cleaned before it is displayed, so scripts embedded in a wiki page can't run.
+- **Graph stays current** — adding or deleting a wiki page updates the Graph
+ and the page list without a reload, and switching back to a workspace that
+ changed in the meantime shows its latest pages.
+- **Graph after a restart** — opening or reloading Switch Bay while Curiosity
+ Engine is restarting (for example right after switching workspaces) now
+ waits for it instead of showing an error.
+
## 2026-10-08 — v0.13.2
**Migration:** none. **Breaking:** none.
diff --git a/docs/THIRD-PARTY-NOTICES.md b/docs/THIRD-PARTY-NOTICES.md
index 025f258..8e083d3 100644
--- a/docs/THIRD-PARTY-NOTICES.md
+++ b/docs/THIRD-PARTY-NOTICES.md
@@ -27,7 +27,8 @@ obligation arises as we do not modify their files.
- **Graph / wiki-view** (`frontend/src/widgets/graph/static/*.js`) is a
fork of **curiosity-engine**'s wiki-view, used under the **MIT License**
- (Copyright (c) 2026, curiosity-engine authors). Full MIT text below.
+ (Copyright (c) 2026, curiosity-engine authors), and now ships only in the
+ VS Code extension's graph webview. Full MIT text below.
- **Knowledge Atlas** (`frontend/src/widgets/graph/static/vendor/knowledge-atlas.js`)
is the first-party IIFE from curiosity-engine's `packages/knowledge-atlas`,
same MIT license.
diff --git a/docs/architecture.md b/docs/architecture.md
index c7688f7..3b515bf 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,4 +1,4 @@
-# Architecture (v0.13)
+# Architecture (v0.14)
How Switchbay **hosts** first-party skills today. Product vocabulary and
data flows stay in [`concepts-and-data-flow.md`](concepts-and-data-flow.md).
@@ -28,8 +28,11 @@ Graph / Agents skill surfaces.
non-loopback rejected).
- Injects `X-CE-Host: switchbay` / `X-Okstratr-Host: switchbay`.
- Vite dev proxies `/embed` to the daemon.
-- Settings → **Proxied skill embeds** switches Graph → CE and Agents →
- okstratr observer; turning it off restores built-ins. The proxy itself
+- The **Graph** tab is always Curiosity Engine's own viewer, mounted
+ through `/embed/ce`. Switchbay has no built-in graph viewer.
+- Settings → **Agents: okstratr observer** (`proxied_skill_embeds`) only
+ chooses the Agents surface: okstratr observer when on, the built-in
+ Agents dashboard when off. It does not affect Graph. The proxy itself
stays on either way.
## Same-document skill mount
@@ -42,14 +45,30 @@ Graph / Agents skill surfaces.
- Implementation: `frontend/src/widgets/embed/` (`ProxiedSkillPanel`,
`embedMount.ts`).
-## Dual-mount Graph
-
-- Shell left column: CE Pages|Files (`CeSidebarSlot`) persists across
- Graph / Agents / Editor when proxied embeds are on for a CE workspace.
-- Graph pane: atlas canvas only (`CeAtlasEmbed`); keeps the atlas warm
- when you leave the tab; `atlas-cache.js` + workspace key for cold Graph cache.
-- Prefers `window.CEEmbed.create` / `mount` when CE provides it; one
- session survives Graph unmount.
+## Graph comes from Curiosity Engine
+
+- Graph, the wiki page list, page modal (incl. note editing), search,
+ type filters, Atlas/Classic, keybindings and curation replay are all
+ CE's viewer, loaded through `/embed/ce` with `window.CEEmbed.create`.
+- `GET /api/settings` reports `ce_graph: {installed, has_wiki}`.
+ CE not installed → the Graph tab is hidden and Graph shortcuts fall back
+ to the Editor. Installed but no `wiki/` yet → a short placeholder.
+- Shell left column: CE Pages (`CeSidebarSlot`) above Switchbay's own
+ Files | Sources browser. The Files uploader, RAG, Agents and Library do
+ not depend on CE.
+- Graph pane: canvas only (`CeAtlasEmbed`, wrapped by `CeGraphSurface`);
+ one `CeEmbedSession` per workspace shares `data.json` between sidebar and
+ canvas, parks the canvas across tab switches and remounts it on wiki
+ `files_changed`.
+- Host shims (`ceHostBridge.ts`, `ceEmbedShell.html`): page HTML is
+ sanitized before CE's modal renders it; KaTeX, table → Sheet / Plot and
+ the slideshow button are added to CE's modal; CE's split panel is routed
+ to `/api/workspaces/split`; Procedure / Execution label-type rows; the
+ sidebar `+` opens Switchbay's upload dialog (`/api/upload-vault`); the
+ sidebar header's `{{WORKSPACE}}` placeholder is filled from `data.json`.
+- The forked viewer under `frontend/src/widgets/graph/` is kept **only**
+ for the VS Code extension's graph webview (`src/webview-graph.ts`); the
+ PWA never imports it.
## Core-skills auto-start
@@ -69,7 +88,8 @@ Graph / Agents skill surfaces.
`local_ingest` (prefer CE drop-ingest endpoint; rail LLM only when
run metadata opts in).
-## Built-in Graph and Agents
+## Built-in Agents
-Built-in GraphTab, AgentDashboardTab, and filebrowser stay available.
-Proxied skill embeds are opt-in in Settings.
+The built-in Agents dashboard and file browser stay available; turning
+off **Agents: okstratr observer** in Settings selects the built-in
+dashboard. There is no built-in Graph.
diff --git a/docs/license-risk-report.md b/docs/license-risk-report.md
index a781fa3..331f507 100644
--- a/docs/license-risk-report.md
+++ b/docs/license-risk-report.md
@@ -17,7 +17,7 @@ what was remediated.
|------|------|--------|
| Proprietary code in the dependency tree | `@univerjs-pro/*` (Univer commercial tier) | ✅ **RESOLVED** — dropped `@univerjs/presets`; built on granular Apache-2.0 packages; Pro tree fully removed (0 in lockfile/bundle); Sheet tab verified working |
| No aggregated attribution file | ~460 MIT/BSD/Apache/ISC deps require notice preservation | ✅ **RESOLVED** — `docs/THIRD-PARTY-NOTICES.md` generated |
-| Forked MIT code without its notice | graph static JS forked from curiosity-engine (MIT) | ✅ **RESOLVED** — MIT attribution header added to the 5 forked files + NOTICES entry |
+| Forked MIT code without its notice | graph static JS forked from curiosity-engine (MIT) | ✅ **RESOLVED** — MIT attribution header added to the forked files + NOTICES entry (since 0.14 the fork ships only in the VS Code graph webview; `edit.js` and `sidebar.js` were removed) |
| Undocumented dual-license elections | jszip, dompurify, certifi, tqdm | ✅ **RESOLVED** — recorded in NOTICES |
**No GPL / AGPL / LGPL / SSPL anywhere** in either dependency tree — the
diff --git a/frontend/package.json b/frontend/package.json
index 4ea60f6..9d7eaaa 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "switchbay-frontend",
"private": true,
- "version": "0.13.2",
+ "version": "0.14.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 189c77d..94a30db 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -29,7 +29,9 @@ import { installKeyRegistry, registerChord, registerCombo } from "./keys";
import { RailSocket, type Mode, type Selection, type ServerMessage, type TabSpec, type Workspaces } from "./ws";
import { applyWebPolicy, bindWebPolicy, loadWebPolicy } from "./lib/webPolicy";
import { SelectionProvider } from "./selection/SelectionContext";
-import "./widgets/graph/load"; // window.Sidebar/Subgraph/Modal/Graph + ce-graph.css
+import "./palette.css"; // theme vars + wiki-type palette + shared markdown view
+import { useCeGraph } from "./widgets/embed/useProxiedSkillEmbeds";
+import { resetCeEmbedSession } from "./widgets/embed/ceEmbedSession";
import type { GraphData } from "./widgets/graph/types";
import Walkthrough, { maybeAutoStartWalkthrough } from "./walkthrough/Walkthrough";
import UploadVaultDialog from "./widgets/upload/UploadVaultDialog";
@@ -415,11 +417,18 @@ export default function App() {
// Tab scoping (control surface v1): thread-scoped tabs render only
// while their thread is focused. Filtered client-side so a thread
// switch shows/hides them instantly, no round-trip.
+ // Graph is Curiosity Engine's viewer; without a CE install the tab
+ // (and every route into it) is hidden rather than shown broken.
+ const ceGraph = useCeGraph();
+ const graphHidden = ceGraph !== null && !ceGraph.installed;
+ const graphHiddenRef = useRef(graphHidden);
+ graphHiddenRef.current = graphHidden;
const visibleTabs = useMemo(
() => mode.tabs.filter(
- (t) => !t.thread || t.thread === focusedThread,
+ (t) => (!t.thread || t.thread === focusedThread)
+ && !(graphHidden && t.kind === "graph"),
),
- [mode, focusedThread],
+ [mode, focusedThread, graphHidden],
);
// If the active tab just went out of scope (thread switch), fall
// back to the first visible one.
@@ -1820,6 +1829,10 @@ export default function App() {
};
}, [setSelection]);
+ /** A new workspace gets a fresh CE embed session (Power remounts the
+ * sidebar per workspace too; this covers Zen, which has none). */
+ useEffect(() => () => { resetCeEmbedSession(); }, [workspace]);
+
/** CE's sidebar + graph.js still write `#page=` to window.location.hash
* when the user clicks. Translate that into a selection-layer dispatch. */
useEffect(() => {
@@ -1846,13 +1859,13 @@ export default function App() {
return () => window.removeEventListener("hashchange", onHash);
}, [setSelection]);
- /** Highlight the active row in the Browser sidebar regardless of which
- * tab is active. Modal/graph effects live in GraphTab (they can only
- * fire when that tab is mounted). */
+ /** Highlight the active row in CE's page sidebar regardless of which
+ * tab is active. Modal/graph effects live in CeAtlasEmbed (they can
+ * only fire when the Graph canvas is mounted). */
useEffect(() => {
if (!graphData) return;
if (!selection || selection.kind !== "page") return;
- try { window.Sidebar.setActive(selection.id); } catch { /* ignore */ }
+ try { window.Sidebar?.setActive(selection.id); } catch { /* ignore */ }
}, [selection, graphData]);
/** One-shot tips to drop into the rail the first time the user opens
@@ -1933,8 +1946,8 @@ export default function App() {
values: detail.values,
});
switchToKindRef.current?.("univer");
- // Don't call window.Modal.close() — its onClose callback
- // (registered in GraphTab.tsx) nulls selection back to null,
+ // Don't call window.Modal.close() — closing the doc modal
+ // (CeAtlasEmbed watches it) nulls selection back to null,
// which clobbers the table-data we just set. The modal stays
// open behind the now-active Sheet tab and the user can dismiss
// it on next visit.
@@ -2410,7 +2423,10 @@ export default function App() {
}, [workspace, pushToast]);
const switchToKind = useCallback(
- (kind: string) => {
+ (kindIn: string) => {
+ // No CE → no Graph tab: page routes that would open the graph's
+ // doc modal open the page in the Editor instead.
+ const kind = kindIn === "graph" && graphHiddenRef.current ? "markdown" : kindIn;
// Zen: the graph is always visible (left pane); every other
// kind lands on the right-pane surface host. Reaching a kind
// clears a pending artifact of that kind — the user got there.
diff --git a/frontend/src/center/builtinTabs.tsx b/frontend/src/center/builtinTabs.tsx
index 56192e3..498d168 100644
--- a/frontend/src/center/builtinTabs.tsx
+++ b/frontend/src/center/builtinTabs.tsx
@@ -2,7 +2,7 @@ import { lazy } from "react";
import SketchErrorBoundary from "../widgets/sketch/ErrorBoundary";
import { registerTabKind, type TabComponent } from "./tabRegistry";
import ProxiedSkillPanel from "../widgets/embed/ProxiedSkillPanel";
-import CeAtlasEmbed from "../widgets/embed/CeAtlasEmbed";
+import CeGraphSurface from "../widgets/embed/CeGraphSurface";
import { useProxiedSkillEmbeds } from "../widgets/embed/useProxiedSkillEmbeds";
/**
@@ -23,7 +23,6 @@ import { useProxiedSkillEmbeds } from "../widgets/embed/useProxiedSkillEmbeds";
* entries can keep a uniform `(ctx) => JSX` shape.
*/
-const GraphTab = lazy(() => import("../widgets/graph/GraphTab"));
const EditorTab = lazy(() => import("../widgets/editor/EditorTab"));
const DuckDBTab = lazy(() => import("../widgets/duckdb/DuckDBTab"));
const SheetTab = lazy(() => import("../widgets/sheet/SheetTab"));
@@ -43,15 +42,8 @@ const ReportDocTab = lazy(() => import("../widgets/library/ReportDocTab"));
const ThrustersTab = lazy(() => import("../widgets/thrusters/ThrustersTab"));
const OwidTab = lazy(() => import("../widgets/owid/OwidTab"));
-const GraphAdapter: TabComponent = ({ graphData, graphError }) => {
- const proxied = useProxiedSkillEmbeds();
- if (proxied === null) {
- return ;
- }
- // Graph = CE atlas embed skin (dedicated mount; not full HTML remount).
- if (proxied) return ;
- return ;
-};
+/** Graph = Curiosity Engine's own viewer, mounted through /embed/ce. */
+const GraphAdapter: TabComponent = () => ;
const EditorAdapter: TabComponent = ({ tab }) => ;
const DuckDBAdapter: TabComponent = () => ;
const SheetAdapter: TabComponent = () => ;
diff --git a/frontend/src/host.ts b/frontend/src/host.ts
index bef28c5..29975b8 100644
--- a/frontend/src/host.ts
+++ b/frontend/src/host.ts
@@ -6,7 +6,7 @@
* in webviews should call this interface; the PWA implements it with
* REST/WS, the plugin with `acquireVsCodeApi().postMessage`.
*
- * This file is the contract. Wiring GraphTab / AgentDashboard / preview
+ * This file is the contract. Wiring the Graph embed / AgentDashboard / preview
* onto it is a later spike step — do not fetch the daemon from a
* plugin webview in the meantime.
*/
diff --git a/frontend/src/index.css b/frontend/src/index.css
index af9d241..cdf386e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -37,7 +37,7 @@
--shadow: 0 12px 36px rgba(20, 30, 50, 0.10);
}
-/* Type palette is defined in widgets/graph/ce-graph.css — that file
+/* Type palette is defined in palette.css — that file
* is the single source of truth for `--type-*` variables (sidebar
* dots, label-types panel, graph node fills via data.palette
* override on the daemon side). Removed the duplicate set from this
@@ -838,28 +838,14 @@ a.sy-source-cite { color: var(--accent); }
font-size: 11px;
}
-/* CE's #sidebar wrapper now lives inside .sy-side-pages. Drop CE's
- * own width/border/background — our column owns the chrome. */
+/* The CE page sidebar (top) and Files|Sources (bottom) split the
+ * column; without CE the Files pane takes the whole column. */
.sy-side-pages {
flex: 1 1 50%;
min-height: 0;
display: flex;
flex-direction: column;
}
-.sy-side-pages-mount {
- flex: 1 1 auto;
- min-height: 0;
- display: flex;
- flex-direction: column;
-}
-.sy-side-pages-mount > #sidebar {
- width: 100%;
- flex: 1 1 auto;
- min-height: 0;
- background: transparent;
- border-right: none;
- transition: none;
-}
.sy-side-files {
flex: 1 1 50%;
min-height: 0;
@@ -867,6 +853,10 @@ a.sy-source-cite { color: var(--accent); }
display: flex;
flex-direction: column;
}
+.sy-side-files--full {
+ flex: 1 1 auto;
+ border-top: none;
+}
.sy-side-bottom {
flex-shrink: 0;
border-top: 1px solid var(--line);
@@ -2539,7 +2529,7 @@ a.sy-source-cite { color: var(--accent); }
min-height: 0;
}
/* Bare = no padding/scroll/overflow handling (the tab widget owns it).
- * Used by GraphTab and other widgets that fill their container. */
+ * Used by the Graph embed and other widgets that fill their container. */
.sy-tab-content--bare {
padding: 0;
overflow: hidden;
@@ -2547,30 +2537,6 @@ a.sy-source-cite { color: var(--accent); }
flex-direction: column;
min-height: 0;
}
-.sy-graph-host {
- position: relative;
- width: 100%;
- height: 100%;
-}
-.sy-graph-progress {
- position: absolute;
- top: 10px;
- left: 50%;
- transform: translateX(-50%);
- z-index: 6;
- padding: 6px 14px;
- border-radius: 999px;
- font-size: 12px;
- color: var(--text);
- background: color-mix(in srgb, var(--bg-soft) 92%, transparent);
- border: 1px solid var(--line);
- box-shadow: 0 2px 8px color-mix(in srgb, #000 18%, transparent);
- pointer-events: none;
- max-width: min(520px, 90%);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
/* ── Sheet tab (Univer) ──────────────────────────────────────────── */
.sy-sheet {
display: flex;
@@ -3041,51 +3007,6 @@ a.sy-source-cite { color: var(--accent); }
}
/* ── Sketch tab (Excalidraw + drawio) ────────────────────────────── */
-/* Opening-graph replay (curation-history animation). Renders as
- * an overlay above the real graph so we can crossfade out once
- * the timeline finishes — the real graph sits underneath, already
- * laid out. */
-.sy-curation-replay {
- position: absolute;
- inset: 0;
- background: var(--bg);
- overflow: hidden;
- z-index: 5;
- opacity: 1;
- /* Fade duration must match the JS setTimeout in GraphTab that
- * unmounts the component (1500ms). Mismatch = either pop or
- * empty canvas while the timeout finishes. */
- transition: opacity 1.4s ease-out;
- /* Pointer events ENABLED here (was none) so d3.zoom on the
- * SVG can receive wheel + drag for manual zoom/pan during the
- * animation. The fading state below disables them so a
- * mid-fade click reaches the real graph beneath. */
-}
-.sy-curation-replay--fading {
- pointer-events: none;
-}
-.sy-curation-replay--fading {
- opacity: 0;
-}
-.sy-curation-replay-svg {
- width: 100%;
- height: 100%;
- display: block;
-}
-.sy-curation-replay-label {
- position: absolute;
- left: 50%;
- bottom: 24px;
- transform: translateX(-50%);
- font-size: 11px;
- color: var(--text-faint);
- letter-spacing: 0.4px;
- pointer-events: none;
- user-select: none;
- opacity: 0.85;
-}
-.sy-curation-replay--fading .sy-curation-replay-label { opacity: 0; }
-
/* Node/edge counter, shared between the replay overlay and the
* real graph. Sits to the left of the Classic/Atlas minimap
* (190px + 12px inset + 12px gap) so the two never stack. */
@@ -3129,58 +3050,6 @@ a.sy-source-cite { color: var(--accent); }
color: #d29922;
border-color: #d29922;
}
-.sy-split-bar {
- position: absolute;
- bottom: 18px;
- left: 50%;
- transform: translateX(-50%);
- z-index: 7;
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 8px 14px;
- background: var(--bg-soft);
- border: 1px solid var(--line);
- border-radius: 12px;
- box-shadow: 0 4px 18px rgba(0, 0, 0, 0.25);
- font-size: 12px;
- max-width: 92%;
-}
-.sy-split-bar-count { font-weight: 600; white-space: nowrap; }
-.sy-split-bar-hint {
- color: var(--text-faint);
- font-size: 10.5px;
- white-space: nowrap;
- cursor: help;
-}
-.sy-split-bar-name {
- width: 180px;
- padding: 3px 8px;
- font-size: 12px;
- background: var(--bg-elev);
- border: 1px solid var(--line);
- border-radius: 6px;
- color: var(--text);
-}
-.sy-split-bar-go {
- padding: 3px 12px;
- border-radius: 6px;
- border: 1px solid #d29922;
- background: transparent;
- color: #d29922;
- font-weight: 600;
- cursor: pointer;
-}
-.sy-split-bar-go:disabled { opacity: 0.5; cursor: default; }
-.sy-split-bar-cancel {
- padding: 3px 10px;
- border-radius: 6px;
- border: 1px solid var(--line);
- background: transparent;
- color: var(--text-faint);
- cursor: pointer;
-}
-.sy-split-bar-err { color: var(--err, #f38ba8); font-size: 11px; }
.sy-graph-replay-btn {
position: absolute;
@@ -5556,10 +5425,6 @@ a.sy-source-cite { color: var(--accent); }
color: var(--text);
}
.sy-duckdb-results tr:hover td { background: var(--bg-elev); }
-.sy-graph-mount {
- width: 100%;
- height: 100%;
-}
.sy-tab-swap {
position: absolute;
top: 8px;
@@ -5829,8 +5694,7 @@ a.sy-source-cite { color: var(--accent); }
.sy-editor-view-chev:hover:not(:disabled) { background: var(--bg-hover, rgba(128, 128, 128, 0.14)); color: var(--text); }
.sy-editor-view-chev:disabled { opacity: 0.3; cursor: default; }
/* Typography (.sy-mdview, .sy-mdview-title, .sy-mdview-properties)
- * lives in widgets/graph/ce-graph.css so it stays in lockstep with
- * #modal-body. */
+ * lives in palette.css next to the wiki-type palette. */
/* Placeholder tab body (replaced step-by-step as each widget lands). */
.sy-placeholder {
@@ -8329,8 +8193,8 @@ a.sy-source-cite { color: var(--accent); }
flex-direction: column;
overflow: hidden;
}
-.sy-zen-browse-pages #sidebar,
-.sy-zen-browse-pages .sy-side-pages-mount {
+.sy-zen-browse-pages .sy-ce-sidebar-slot,
+.sy-zen-browse-pages #sidebar {
flex: 1 1 auto;
min-height: 0;
display: flex;
@@ -8961,9 +8825,9 @@ a.sy-source-cite { color: var(--accent); }
Power mode (graph as a center tab, no floating chrome) is untouched. */
.sy-zen-left .sy-tab-swap { top: 44px; }
/* In the graph, the "↗ Editor" tab-swap shares the top-right corner
- with the forked viewer's physics-settings gear (.settings-trigger,
- right:14px). Shift it left of the gear so it never covers it. */
-.sy-graph-host .sy-tab-swap { right: 52px; }
+ with CE's physics-settings gear (.settings-trigger, right:14px).
+ Shift it left of the gear so it never covers it. */
+.sy-ce-atlas-embed .sy-tab-swap { right: 52px; }
/* ── OWID pack browse tab ─────────────────────────────────────────── */
.sy-owid { display: flex; flex-direction: column; height: 100%; min-height: 0;
@@ -9265,16 +9129,40 @@ body.sy-embed-hosting {
font-size: 0.85em;
}
-/* Dual-mount Graph: Switchbay ↻/✂ sit top-left (prior GraphTab chrome).
+/* CE's page modal sizes itself against the full viewport (it assumes
+ * it owns the window minus its own sidebar). Inside the Graph tab it
+ * would run under the rail, so fit it to the graph pane instead. */
+.sy-ce-atlas-embed-root #modal-backdrop {
+ left: 0 !important;
+}
+.sy-ce-atlas-embed-root #modal {
+ top: 4% !important;
+ bottom: 4% !important;
+ left: 8% !important;
+ width: 84% !important;
+}
+/* Slideshow button sits left of CE's padlock (right:44px) and close X. */
+.sy-ce-atlas-embed-root .modal-slides {
+ position: absolute;
+ top: 14px;
+ right: 74px;
+ z-index: 51;
+ color: var(--text-faint);
+}
+.sy-ce-atlas-embed-root .modal-slides:hover { color: var(--text-muted); }
+.sy-ce-atlas-embed-root .modal-slides:disabled { opacity: 0.4; cursor: progress; }
+
+/* CE Graph: Switch Bay ↻/✂ sit top-left.
Hide CE's bottom-bar copies so we don't double up. */
.sy-ce-atlas-embed-root #split-toggle,
.sy-ce-atlas-embed-root #replay-toggle {
display: none !important;
}
-/* CE dual-mount: shell-left sidebar slot (Pages|Files) */
+/* CE dual-mount: shell-left sidebar slot (Pages|Files). Shares the
+ * column 50/50 with Switch Bay's Files|Sources pane below it. */
.sy-side-pages--ce-embed {
- flex: 1 1 auto;
+ flex: 1 1 50%;
min-height: 0;
display: flex;
flex-direction: column;
diff --git a/frontend/src/layout/SettingsModal.tsx b/frontend/src/layout/SettingsModal.tsx
index 2b3b0e7..f72885a 100644
--- a/frontend/src/layout/SettingsModal.tsx
+++ b/frontend/src/layout/SettingsModal.tsx
@@ -4058,19 +4058,18 @@ function StoragePanel({ open }: { open: boolean }) {
- Proxied skill embeds (Phase 4a)
+ Agents: okstratr observer
- When on: shell left hosts CE Pages|Files (persistent); Graph hosts
- CE atlas canvas only — one embed session, two mounts, shared{" "}
- /embed/ce/data.json. Agents mounts okstratr under{" "}
- /embed/okstratr. No iframes / full CE document remount.
- Default off keeps WikiPane + GraphTab / AgentDashboard.
+ When on, the Agents tab shows okstratr's own observer (served
+ under /embed/okstratr). When off, it shows Switch
+ Bay's built-in Agents dashboard. The Graph tab always uses
+ Curiosity Engine's viewer and isn't affected by this switch.
- Proxied embeds: {" "}
- {settings.proxied_skill_embeds ? "on (Graph→CE canvas, Agents→okstratr)" : "off (built-in)"}
+ okstratr observer: {" "}
+ {settings.proxied_skill_embeds ? "on" : "off (built-in dashboard)"}
summary {
+ cursor: pointer;
+ color: var(--text-muted);
+ font-size: 12px;
+ list-style: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ user-select: none;
+}
+.sy-prop-list > summary::-webkit-details-marker { display: none; }
+.sy-prop-list > summary::before {
+ content: "▸";
+ font-size: 10px;
+ color: var(--text-faint);
+ line-height: 1;
+}
+.sy-prop-list[open] > summary::before { content: "▾"; }
+.sy-prop-list > summary:hover { color: var(--text); }
+.sy-prop-list > div { margin-top: 3px; }
+.sy-prop-list > summary + div { margin-top: 6px; }
+
+.sy-graph-count {
+ position: absolute;
+ bottom: 12px;
+ right: 214px;
+ font-size: 11px;
+ color: var(--text-faint);
+ font-variant-numeric: tabular-nums;
+ user-select: none;
+ pointer-events: none;
+ letter-spacing: 0.3px;
+ z-index: 6;
+}
+.sy-graph-replay-btn {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ z-index: 15;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ border: 1px solid var(--line);
+ background: var(--bg-soft);
+ color: var(--text-muted);
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.sy-graph-replay-btn:hover:not(:disabled) {
+ color: var(--text);
+ border-color: var(--text-muted);
+}
+.sy-graph-replay-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/sidebar/Sidebar.tsx b/frontend/src/sidebar/Sidebar.tsx
index 0807198..d1dffce 100644
--- a/frontend/src/sidebar/Sidebar.tsx
+++ b/frontend/src/sidebar/Sidebar.tsx
@@ -2,12 +2,11 @@ import { useEffect, useState } from "react";
import type { GraphData } from "../widgets/graph/types";
import FileBrowser from "./FileBrowser";
import SourceBrowser from "./SourceBrowser";
-import WikiPane from "./WikiPane";
import { useIngestDrop } from "./ingestDrop";
import ThemeToggle from "../layout/ThemeToggle";
import ModeToggle from "../layout/ModeToggle";
import CeSidebarSlot from "../widgets/embed/CeSidebarSlot";
-import { useProxiedSkillEmbeds } from "../widgets/embed/useProxiedSkillEmbeds";
+import { useCeGraph } from "../widgets/embed/useProxiedSkillEmbeds";
import { resetCeEmbedSession } from "../widgets/embed/ceEmbedSession";
type Props = {
@@ -55,8 +54,10 @@ function BottomSeg({
/**
* Switch Bay Browser (Power mode). Stacks two views vertically inside
* the left column:
- * - top: CE's forked sidebar (page list grouped by type, search,
- * `+` upload) — the wiki stays always in view.
+ * - top: Curiosity Engine's own page sidebar (Pages|Files, search,
+ * `+` upload), mounted through /embed/ce and shared with the Graph
+ * canvas — the wiki stays always in view. Only shown when CE is
+ * installed and the workspace has a wiki.
* - bottom: the lower-level browse surface — Files|Sources toggle
* (D1) between the on-disk file tree (OS-style ops) and the
* provenance tree over external `extracted_from` paths.
@@ -68,9 +69,9 @@ function BottomSeg({
* Zen mode shows the same three browsers side by side instead — see
* ZenBrowserTab, which composes the same parts.
*/
-export default function Sidebar({ data, error, filesVersion }: Props) {
- const proxied = useProxiedSkillEmbeds();
- const [refreshKey, setRefreshKey] = useState(0);
+export default function Sidebar({ error, filesVersion }: Props) {
+ const ce = useCeGraph();
+ const showWiki = !!ce?.installed && ce.hasWiki;
// Bottom-pane mode (D1): the wiki page list stays always-visible on
// top; the BOTTOM pane is the lower-level browse surface and flips
// between the on-disk file tree and the external-sources
@@ -106,48 +107,39 @@ export default function Sidebar({ data, error, filesVersion }: Props) {
)}
- {proxied === null ? (
+ {ce === null ? (
- ) : proxied === true ? (
- // One CE page list (Pages|Files) in the shell — persists across
- // Graph/Agents/Editor. Graph pane is canvas-only via CeAtlasEmbed.
+ ) : showWiki ? (
+ // CE's page list (Pages|Files) persists across Graph/Agents/Editor;
+ // the Graph tab mounts the canvas from the same embed session.
- ) : (
- <>
-
- setRefreshKey((k) => k + 1)}
- />
-
- {error && (
-
- {error}
-
- )}
-
- {view === "files" ? (
-
- }
- />
- ) : (
-
- }
- />
- )}
-
- >
+ ) : null}
+ {error && showWiki && (
+
+ {error}
+
)}
+
+ {view === "files" ? (
+
+ }
+ />
+ ) : (
+
+ }
+ />
+ )}
+
diff --git a/frontend/src/sidebar/WikiPane.tsx b/frontend/src/sidebar/WikiPane.tsx
deleted file mode 100644
index 4cc876d..0000000
--- a/frontend/src/sidebar/WikiPane.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { useEffect, useRef } from "react";
-import { sidebarTemplate } from "../widgets/graph/template";
-import type { GraphData } from "../widgets/graph/types";
-import { openUploadVaultDialog } from "../lib/uploadVault.ts";
-
-/**
- * The wiki page list — CE's forked sidebar (pages grouped by type,
- * search, `+` upload), mounted into a slot and driven imperatively via
- * `window.Sidebar`.
- *
- * Shared by the Power sidebar column and the Zen Browser surface.
- * `window.Sidebar` is a SINGLETON over fixed element ids (`#sidebar`,
- * `#sidebar-list`, …), so exactly one of these may be mounted at a
- * time. That holds today because Power and Zen are mutually exclusive
- * shells — if that ever stops being true, this needs an instance-
- * scoped fork of the CE template, not a second mount.
- */
-
-type Props = {
- data: GraphData | null;
- /** Fired when a NEW graph build lands — a rebuilt wiki means the
- * on-disk file set may have shifted too, so consumers bump their
- * browsers' refreshKey. */
- onGraphBuild?: () => void;
-};
-
-export default function WikiPane({ data, onGraphBuild }: Props) {
- const slotRef = useRef(null);
- const lastDataRef = useRef(null);
- // Kept in a ref so the mount effect doesn't re-run (and re-init the
- // CE sidebar) just because the parent passed a new closure.
- const onBuildRef = useRef(onGraphBuild);
- useEffect(() => { onBuildRef.current = onGraphBuild; }, [onGraphBuild]);
-
- useEffect(() => {
- if (!slotRef.current) return;
- if (!slotRef.current.querySelector("#sidebar")) {
- slotRef.current.innerHTML = sidebarTemplate;
- }
- if (data && data !== lastDataRef.current) {
- lastDataRef.current = data;
- window.Sidebar.init(data);
- onBuildRef.current?.();
- } else if (!data) {
- // Workspace switched to one with no built graph (data === null).
- // window.Sidebar.init only renders for truthy data, so without
- // this the previous workspace's page list stays in #sidebar-list.
- // Clear it explicitly so the Browser empties on the switch.
- lastDataRef.current = null;
- const list = slotRef.current.querySelector("#sidebar-list");
- if (list) list.innerHTML = "";
- const search = slotRef.current.querySelector("#sidebar-search");
- if (search) search.value = "";
- }
- }, [data]);
-
- // Wire the CE-template `#sidebar-upload` button to the upload dialog.
- // Hooking up after the template lands; runs after every re-mount
- // (workspace switch re-injects the markup).
- useEffect(() => {
- const btn = slotRef.current?.querySelector("#sidebar-upload");
- if (!btn) return;
- btn.setAttribute("data-tour", "add-files");
- const onClick = () => openUploadVaultDialog();
- btn.addEventListener("click", onClick);
- return () => btn.removeEventListener("click", onClick);
- }, [data]);
-
- return
;
-}
diff --git a/frontend/src/walkthrough/Walkthrough.tsx b/frontend/src/walkthrough/Walkthrough.tsx
index 2ab8624..f0c41a6 100644
--- a/frontend/src/walkthrough/Walkthrough.tsx
+++ b/frontend/src/walkthrough/Walkthrough.tsx
@@ -80,7 +80,7 @@ function buildSteps(): Step[] {
+ "files to vault/raw/, ticking Ingest automatically to ingest them "
+ "too.",
target: "[data-tour='add-files']",
- targets: ["[data-tour='browser']"],
+ targets: ["[data-testid='fb-upload-vault']", "[data-tour='browser']"],
requireMode: "power",
enter: (ctx) => {
ctx.setSettingsOpen(false);
@@ -162,7 +162,7 @@ function buildSteps(): Step[] {
id: hub.id,
path: page?.path ?? hub.path ?? hub.id,
});
- // CE modal opens from selection via GraphTab effect.
+ // CeAtlasEmbed opens CE's page modal from the selection.
await sleep(350);
} else {
await sleep(100);
diff --git a/frontend/src/webview-graph.ts b/frontend/src/webview-graph.ts
index 57e0946..b4a5011 100644
--- a/frontend/src/webview-graph.ts
+++ b/frontend/src/webview-graph.ts
@@ -108,7 +108,6 @@ async function bind(data: GraphData): Promise {
};
mountGraph(mount, themed, {
onSelectPage: (id) => openNode(themed, id),
- skipEdit: true,
});
const pane = mount.querySelector("#graph-pane");
if (pane instanceof HTMLElement) {
diff --git a/frontend/src/widgets/embed/CeAtlasEmbed.tsx b/frontend/src/widgets/embed/CeAtlasEmbed.tsx
index 797c2b5..7d80bbf 100644
--- a/frontend/src/widgets/embed/CeAtlasEmbed.tsx
+++ b/frontend/src/widgets/embed/CeAtlasEmbed.tsx
@@ -1,24 +1,51 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import canvasHtml from "./ceEmbedShell.html?raw";
import { getCeEmbedSession } from "./ceEmbedSession.ts";
+import {
+ bindSlideshowButton,
+ currentModalPageId,
+ enhanceCeModalBody,
+} from "./ceHostBridge.ts";
+import { useSelection } from "../../selection/SelectionContext";
+import { useTabs } from "../../center/TabsContext";
+import { openUploadVaultDialog } from "../../lib/uploadVault.ts";
/**
- * Graph tab = CE atlas canvas only (graph-search + interactions).
- * Pages|Files live in the shell left column via CeSidebarSlot — same
- * CeEmbedSession / data.json (no second page list, no full HTML remount).
+ * Graph = Curiosity Engine's own viewer (atlas/classic canvas, search,
+ * page modal, editing, replay, split). Pages|Files live in the shell
+ * left column via CeSidebarSlot — same CeEmbedSession / data.json.
*
- * Leave Graph → Agents/Editor parks the atlas off-DOM (no destroy).
+ * Leave Graph → Agents/Editor parks the canvas off-DOM (no destroy).
* Return reattaches instantly; optional background data.json revalidate.
+ *
+ * Switch Bay adds a thin layer on top:
+ * - selection sync: a page picked anywhere in the shell (⌘K, Editor,
+ * walkthrough, agent) opens in CE's modal; closing the modal clears it.
+ * - Zen (`suppressDocModal`): pages open in the right-pane Editor, so
+ * CE's modal is closed again and only the node focus stays.
+ * - modal extras (KaTeX, table → Sheet/Plot, slideshow) — ceHostBridge.
+ * - ↻ / ✂ shortcuts, ↗ Editor jump, node/edge count, Zen `+` upload.
*/
const SOFT_REMOUNT_GRACE_MS = 8000;
const SOFT_REMOUNT_DEBOUNCE_MS = 1500;
+/** localStorage key CE's atlas.js reads for the Atlas / Classic choice. */
+const CE_VIEWER_STORAGE_KEY = "curiosity-engine.viewer";
type LoadState =
| { status: "loading" }
| { status: "ready" }
| { status: "error"; message: string };
+type CePageLite = { id?: string; path?: string; type?: string };
+type CeDataLite = {
+ nodes?: unknown[];
+ edges?: unknown[];
+ pages?: Record;
+};
+
+type CeViewerApi = { focus?: (id: string) => void; clearFocus?: () => void };
+
function stateFromSession(): LoadState {
const st = getCeEmbedSession().getState();
if (st.status === "ready") return { status: "ready" };
@@ -26,12 +53,52 @@ function stateFromSession(): LoadState {
return { status: "loading" };
}
-export default function CeAtlasEmbed() {
+function sessionData(): CeDataLite | null {
+ const st = getCeEmbedSession().getState();
+ return st.status === "ready" ? (st.data as CeDataLite) : null;
+}
+
+function ceModal(): { open?: (id: string) => unknown; close?: () => void } | undefined {
+ return (window as unknown as { Modal?: { open?: (id: string) => unknown; close?: () => void } }).Modal;
+}
+
+function ceViewer(): CeViewerApi | undefined {
+ return (window as unknown as { CEViewer?: CeViewerApi }).CEViewer;
+}
+
+type Props = {
+ /** Zen: show a `+` add-file button next to ↻/✂ (Power has it in the sidebar). */
+ showAddFile?: boolean;
+ /** Zen: page selections open in the right-pane Editor; keep CE's modal shut. */
+ suppressDocModal?: boolean;
+};
+
+export default function CeAtlasEmbed({ showAddFile, suppressDocModal }: Props = {}) {
const rootRef = useRef(null);
const readyAtRef = useRef(0);
// Seed from session so Graph return never flashes "Loading…" when atlas
// is already warm (soft-parked across tab switches).
const [state, setState] = useState(() => stateFromSession());
+ const [counts, setCounts] = useState<{ nodes: number; edges: number } | null>(null);
+ const [modalOpen, setModalOpen] = useState(false);
+ // Daemon graph-build progress ("Rebuilding graph… 40%") while loading.
+ const [progressMsg, setProgressMsg] = useState(null);
+ useEffect(() => {
+ const onProg = (ev: Event) => {
+ const d = (ev as CustomEvent<{ message?: string; done?: boolean }>).detail;
+ if (!d) return;
+ setProgressMsg(d.done ? null : d.message || null);
+ };
+ window.addEventListener("sy:graph-progress", onProg);
+ return () => window.removeEventListener("sy:graph-progress", onProg);
+ }, []);
+ const { selection, setSelection } = useSelection();
+ const { tabs, switchToKind } = useTabs();
+ const hasEditor = useMemo(() => tabs.some((t) => t.kind === "markdown"), [tabs]);
+ const suppressRef = useRef(!!suppressDocModal);
+ suppressRef.current = !!suppressDocModal;
+ const selectionRef = useRef(selection);
+ selectionRef.current = selection;
useEffect(() => {
const el = rootRef.current;
@@ -42,8 +109,11 @@ export default function CeAtlasEmbed() {
const sync = () => {
const st = session.getState();
if (st.status === "ready") {
- readyAtRef.current = Date.now();
+ // Grace window starts at first paint, not at every data refresh.
+ if (readyAtRef.current === 0) readyAtRef.current = Date.now();
setState({ status: "ready" });
+ const d = st.data as CeDataLite | null;
+ setCounts({ nodes: d?.nodes?.length ?? 0, edges: d?.edges?.length ?? 0 });
} else if (st.status === "error") {
setState({ status: "error", message: st.message });
} else if (st.status === "loading" || st.status === "idle") {
@@ -71,17 +141,44 @@ export default function CeAtlasEmbed() {
if (Date.now() - readyAtRef.current < SOFT_REMOUNT_GRACE_MS) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
- // Wiki changed: drop cached layout, refetch data, hard remount.
- session.invalidateAtlasLayoutCache();
- session.softRevalidate();
- session.detachCanvas(el, { destroy: true });
- session.attachCanvas(el, canvasHtml);
+ // Wiki changed: wait for fresh data, then hard remount on it.
+ void session.refresh().then(() => {
+ if (!el.isConnected) return;
+ session.invalidateAtlasLayoutCache();
+ session.detachCanvas(el, { destroy: true });
+ session.attachCanvas(el, canvasHtml);
+ });
}, SOFT_REMOUNT_DEBOUNCE_MS);
};
window.addEventListener("sy:files-changed", onFiles);
+ // CE's view: (Atlas ⇄ Classic) chooser reloads the whole page, which
+ // inside Switchbay would reload the app. Store the same choice CE
+ // reads and remount just the canvas instead.
+ const onViewerMode = (ev: MouseEvent) => {
+ const target = ev.target as Element | null;
+ const btn = target?.closest?.("#viewer-mode");
+ if (!btn || !el.contains(btn)) return;
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+ const current = (el.querySelector("#viewer-mode-state")?.textContent || "").trim();
+ const next = current === "atlas" ? "classic" : "atlas";
+ try { localStorage.setItem(CE_VIEWER_STORAGE_KEY, next); } catch { /* ignore */ }
+ try {
+ const url = new URL(window.location.href);
+ if (url.searchParams.has("viewer")) {
+ url.searchParams.delete("viewer");
+ window.history.replaceState(window.history.state, "", url.toString());
+ }
+ } catch { /* ignore */ }
+ session.detachCanvas(el, { destroy: true });
+ session.attachCanvas(el, canvasHtml);
+ };
+ el.addEventListener("click", onViewerMode, true);
+
return () => {
if (timer) clearTimeout(timer);
+ el.removeEventListener("click", onViewerMode, true);
window.removeEventListener("sy:files-changed", onFiles);
unsub();
// Soft park — atlas + layout stay warm for Agents/Editor round-trip.
@@ -89,6 +186,106 @@ export default function CeAtlasEmbed() {
};
}, []);
+ // Watch CE's modal: decorate each rendered page, mirror open/close
+ // into the selection layer, and keep it shut in Zen.
+ useEffect(() => {
+ const root = rootRef.current;
+ if (!root) return;
+ let modal: HTMLElement | null = null;
+ let body: HTMLElement | null = null;
+ let wasOpen = false;
+ const pageOf = (id: string | null) => (id ? sessionData()?.pages?.[id] : undefined);
+
+ const decorate = () => {
+ if (!body || !modal) return;
+ const id = currentModalPageId();
+ const page = pageOf(id);
+ bodyObs.disconnect();
+ try {
+ enhanceCeModalBody(body, page?.path || id || "graph-modal");
+ bindSlideshowButton(
+ modal,
+ () => pageOf(currentModalPageId())?.path || null,
+ () => String(pageOf(currentModalPageId())?.type || ""),
+ );
+ } finally {
+ bodyObs.observe(body, { childList: true });
+ }
+ };
+ const onModalAttr = () => {
+ if (!modal) return;
+ const open = modal.getAttribute("aria-hidden") === "false";
+ setModalOpen(open);
+ if (open && suppressRef.current) {
+ // Zen: App already routed the page to the Editor (same hashchange);
+ // close on the next task so its listener saw the hash first.
+ const id = currentModalPageId();
+ window.setTimeout(() => {
+ try { ceModal()?.close?.(); } catch { /* ignore */ }
+ if (id) {
+ try { ceViewer()?.focus?.(id); } catch { /* ignore */ }
+ }
+ }, 0);
+ } else if (!open && wasOpen && !suppressRef.current) {
+ // Power: closing the doc modal drops the page selection.
+ const sel = selectionRef.current;
+ if (sel && sel.kind === "page") setSelection(null);
+ }
+ wasOpen = open;
+ };
+ const bodyObs = new MutationObserver(decorate);
+ const attrObs = new MutationObserver(onModalAttr);
+ const bind = () => {
+ const m = root.querySelector("#modal");
+ if (!m || m === modal) return;
+ attrObs.disconnect();
+ bodyObs.disconnect();
+ modal = m;
+ body = m.querySelector("#modal-body");
+ attrObs.observe(m, { attributes: true, attributeFilter: ["aria-hidden"] });
+ if (body) bodyObs.observe(body, { childList: true });
+ wasOpen = m.getAttribute("aria-hidden") === "false";
+ setModalOpen(wasOpen);
+ if (wasOpen) decorate();
+ };
+ // The canvas markup is (re)injected by the session on mount/remount.
+ const rootObs = new MutationObserver(bind);
+ rootObs.observe(root, { childList: true });
+ bind();
+ return () => {
+ rootObs.disconnect();
+ attrObs.disconnect();
+ bodyObs.disconnect();
+ };
+ }, [setSelection]);
+
+ // Selection → CE. A page picked anywhere in the shell shows here: Power
+ // opens it in CE's modal (via the #page= hash CE listens to); Zen only
+ // focuses the node. Re-applied when the canvas becomes ready, so a
+ // selection made on another tab is still there on return.
+ useEffect(() => {
+ if (state.status !== "ready") return;
+ const id = selection && selection.kind === "page" ? selection.id : null;
+ if (suppressDocModal) {
+ try {
+ if (id) ceViewer()?.focus?.(id);
+ } catch { /* ignore */ }
+ return;
+ }
+ if (id) {
+ if (currentModalPageId() !== id) {
+ window.location.hash = "#page=" + encodeURIComponent(id);
+ } else if (!modalOpen) {
+ try { ceModal()?.open?.(id); } catch { /* ignore */ }
+ try { ceViewer()?.focus?.(id); } catch { /* ignore */ }
+ }
+ } else if (modalOpen) {
+ try { ceModal()?.close?.(); } catch { /* ignore */ }
+ }
+ // modalOpen is read, not tracked: a user close must not re-open.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selection, state.status, suppressDocModal]);
+
const clickCe = (sel: string) => {
const btn = rootRef.current?.querySelector(sel);
if (btn instanceof HTMLElement) btn.click();
@@ -97,6 +294,7 @@ export default function CeAtlasEmbed() {
// Only show the slow loading banner when we do not already have a live atlas.
const showLoading =
state.status === "loading" && !getCeEmbedSession().hasLiveCanvas();
+ const live = state.status === "ready" || getCeEmbedSession().hasLiveCanvas();
return (
{showLoading && (
-
Loading Curiosity Engine atlas…
+
+ {progressMsg ?? "Loading Curiosity Engine graph…"}
+
)}
{state.status === "error" && (
-
CE atlas embed failed.
+
The Curiosity Engine graph couldn't load.
{state.message}
-
- Dual-mount session: shell hosts CE Pages|Files; this pane is
- canvas-only. See docs/architecture.md.
-
+
{
+ // ensure() re-runs after an error; re-attaching retries the boot.
+ const el = rootRef.current;
+ if (el) getCeEmbedSession().attachCanvas(el, canvasHtml);
+ }}
+ >
+ Retry
+
)}
- {(state.status === "ready" || getCeEmbedSession().hasLiveCanvas()) && (
+ {live && (
<>
✂
+ {showAddFile && (
+
+ +
+
+ )}
+ {hasEditor && !suppressDocModal && selection?.kind === "page" && (
+
switchToKind("markdown")}
+ title="Open the current page in the Editor tab"
+ >
+ ↗ Editor
+
+ )}
+ {counts && (
+
+ {counts.nodes} nodes · {counts.edges} edges
+
+ )}
>
)}
;
+ }
+ if (!ce.installed) {
+ return (
+
+
Graph
+
+ The Graph comes from Curiosity Engine, which isn't installed.
+ Files, search, Agents and the rest of Switch Bay work without it.
+
+
+ );
+ }
+ if (!ce.hasWiki) {
+ return (
+
+
Graph
+
This workspace has no wiki/ yet, so there's no graph to show.
+
+ );
+ }
+ return
;
+}
diff --git a/frontend/src/widgets/embed/CeSidebarSlot.tsx b/frontend/src/widgets/embed/CeSidebarSlot.tsx
index b432958..ac880d9 100644
--- a/frontend/src/widgets/embed/CeSidebarSlot.tsx
+++ b/frontend/src/widgets/embed/CeSidebarSlot.tsx
@@ -35,9 +35,25 @@ export default function CeSidebarSlot() {
};
}, []);
+ // Keep the page list current when the wiki changes while Graph isn't
+ // open (Graph runs the same refresh itself and shares the result).
+ useEffect(() => {
+ let timer: ReturnType
| null = null;
+ const onFiles = () => {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(() => { void getCeEmbedSession().refresh(); }, 1500);
+ };
+ window.addEventListener("sy:files-changed", onFiles);
+ return () => {
+ if (timer) clearTimeout(timer);
+ window.removeEventListener("sy:files-changed", onFiles);
+ };
+ }, []);
+
// CE's edit.js binds its `+` (#sidebar-upload) to a bare file picker.
- // Catch the click on the way down and open Switchbay's upload dialog
- // instead, so both sidebar modes share one uploader.
+ // Catch the click on the way down and open Switch Bay's upload dialog
+ // instead (vault/raw/ via /api/upload-vault, optional ingest), the same
+ // one the Files toolbar uses.
useEffect(() => {
const el = ref.current;
if (!el) return;
diff --git a/frontend/src/widgets/embed/ceEmbedBootstrap.ts b/frontend/src/widgets/embed/ceEmbedBootstrap.ts
index d91dc23..0b88596 100644
--- a/frontend/src/widgets/embed/ceEmbedBootstrap.ts
+++ b/frontend/src/widgets/embed/ceEmbedBootstrap.ts
@@ -14,6 +14,7 @@
* }) => { destroy(): void }
*/
+import { guardCeModal, guardCeSidebar, routeCeSplit } from "./ceHostBridge.ts";
import { isSwitchbayReservedApi } from "./embedMount.ts";
export type CeEmbedOptions = {
embed: true;
@@ -53,7 +54,7 @@ type CeEmbedGlobal = {
mount: (container: HTMLElement, opts: CeEmbedOptions) => CeEmbedHandle | Promise;
};
-/** Loose CE globals (wiki-view IIFEs). Avoid augmenting Window — GraphTab already does. */
+/** Loose CE globals (wiki-view IIFEs). Avoid augmenting Window — static.d.ts already does. */
type CeWindow = Window & {
CEEmbed?: CeEmbedGlobal;
CE_PUBLIC_BASE?: string;
@@ -128,7 +129,11 @@ function loadScript(src: string): Promise {
el.dataset.loaded = "1";
resolve();
};
- el.onerror = () => reject(new Error(`Failed to load ${src}`));
+ el.onerror = () => {
+ // Drop the dead tag so a retry (CE viewer restarting) loads it again.
+ el.remove();
+ reject(new Error(`Failed to load ${src}`));
+ };
document.head.appendChild(el);
});
}
@@ -183,6 +188,7 @@ async function installFetchShim(publicBase: string): Promise<() => void> {
el.textContent = shim.content ?? "";
document.head.appendChild(el);
el.remove();
+ routeCeSplit(publicBase);
return () => {
try {
w().__syEmbedReleaseFetch?.();
@@ -195,6 +201,16 @@ async function installFetchShim(publicBase: string): Promise<() => void> {
async function loadCeModules(publicBase: string): Promise {
const base = publicBase.replace(/\/$/, "") || "";
loadCss(`${base}/static/main.css`);
+ try {
+ await loadOrdered(base);
+ } finally {
+ // CE renders page HTML with innerHTML on the daemon origin.
+ guardCeModal();
+ guardCeSidebar();
+ }
+}
+
+async function loadOrdered(base: string): Promise {
for (const rel of SCRIPT_ORDER) {
// split/replay may 404 on older wiki-view bundles — skip soft-fail
try {
@@ -405,6 +421,21 @@ export async function prepareSession(
installPublicBase(publicBase);
const releaseFetch = await installFetchShim(publicBase);
+ try {
+ return await prepareWithShim(opts, publicBase, dataUrl, releaseFetch);
+ } catch (e) {
+ // Leave no shim behind, so a retry starts clean.
+ releaseFetch();
+ throw e;
+ }
+}
+
+async function prepareWithShim(
+ opts: CeEmbedOptions,
+ publicBase: string,
+ dataUrl: string,
+ releaseFetch: () => void,
+): Promise {
await loadCeModules(publicBase);
const ce = w().CEEmbed as
diff --git a/frontend/src/widgets/embed/ceEmbedSession.ts b/frontend/src/widgets/embed/ceEmbedSession.ts
index 3247b96..efb6b36 100644
--- a/frontend/src/widgets/embed/ceEmbedSession.ts
+++ b/frontend/src/widgets/embed/ceEmbedSession.ts
@@ -17,6 +17,7 @@ import {
type CeEmbedHandle,
type CeEmbedOptions,
} from "./ceEmbedBootstrap.ts";
+import { renderSidebarShell, workspaceLabel } from "./ceSidebarHeader.ts";
export type CeMountPoints = {
sidebar?: HTMLElement | null;
@@ -74,22 +75,35 @@ function win(): CeWin {
}
-function workspaceLabel(data: unknown): string {
- if (data && typeof data === "object" && "workspace" in data) {
- const w = (data as { workspace?: unknown }).workspace;
- if (typeof w === "string" && w.trim()) {
- const parts = w.split("/").filter(Boolean);
- return parts[parts.length - 1] || w;
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+/**
+ * The CE viewer restarts on workspace switch and after an update. Wait
+ * (bounded) until the daemon reports it healthy so the first load doesn't
+ * land on a 502 mid-restart. Unknown / unreachable status → go ahead.
+ */
+async function waitForCeHealthy(timeoutMs = 60_000): Promise {
+ const until = Date.now() + timeoutMs;
+ while (Date.now() < until) {
+ try {
+ const r = await fetch("/api/core-skills/status", { cache: "no-store" });
+ if (!r.ok) return;
+ const s = (await r.json()) as { ce?: { state?: string } };
+ const st = s?.ce?.state;
+ if (!st || st === "healthy") return;
+ } catch {
+ return;
}
+ await sleep(1000);
}
- return "workspace";
}
+const BOOT_ATTEMPTS = 3;
+
function injectSidebarHtml(el: HTMLElement, html: string, data: unknown): void {
- const label = workspaceLabel(data);
- el.innerHTML = html.replace(/\{\{WORKSPACE\}\}/g, label);
+ el.innerHTML = renderSidebarShell(html, data);
const nameEl = el.querySelector(".workspace-name");
- if (nameEl) nameEl.textContent = label;
+ if (nameEl) nameEl.textContent = workspaceLabel(data);
}
class CeEmbedSession {
@@ -136,6 +150,57 @@ class CeEmbedSession {
}
}
+ private refreshPromise: Promise | null = null;
+ private refreshAgain = false;
+
+ /**
+ * The wiki changed on disk: re-read data.json and wait for it. CE's
+ * `revalidate` refreshes its modal / search / file browser but not the
+ * page list, so re-init that too, then publish the new data (counts).
+ * Callers remount the canvas after this resolves so it draws the new
+ * graph. Calls made while one is in flight run once more afterwards.
+ */
+ refresh(): Promise {
+ if (this.refreshPromise) {
+ this.refreshAgain = true;
+ return this.refreshPromise;
+ }
+ const run = async () => {
+ do {
+ this.refreshAgain = false;
+ const h = this.createHandle;
+ if (!h?.revalidate || this.state.status !== "ready") return;
+ try {
+ await h.revalidate();
+ } catch {
+ /* keep current data */
+ }
+ const fresh = h.getData?.();
+ if (!fresh || this.createHandle !== h || this.state.status !== "ready") return;
+ try {
+ // Re-renders the grouped list (throws after that when the
+ // canvas isn't mounted; the list is already updated by then).
+ win().Sidebar?.init(fresh);
+ } catch {
+ /* ignore */
+ }
+ // init() also appends a "show sidebar" button each time; keep one.
+ document.querySelectorAll("#graph-pane .sidebar-restore").forEach((b, i) => {
+ if (i > 0) b.remove();
+ });
+ if (this.sidebarEl) {
+ const nameEl = this.sidebarEl.querySelector(".workspace-name");
+ if (nameEl) nameEl.textContent = workspaceLabel(fresh);
+ }
+ this.setState({ status: "ready", data: fresh });
+ } while (this.refreshAgain);
+ };
+ this.refreshPromise = run().finally(() => {
+ this.refreshPromise = null;
+ });
+ return this.refreshPromise;
+ }
+
/** Drop cached layout positions (tip / files_changed) so next mount re-layouts. */
invalidateAtlasLayoutCache(): void {
try {
@@ -180,12 +245,22 @@ class CeEmbedSession {
// mountCeAtlas with a throwaway off-DOM node is heavy; load via
// a dedicated path: import bootstrap internals.
const { prepareSession } = await import("./ceEmbedBootstrap.ts");
- const prepared = await prepareSession({
- embed: true,
- dataUrl: DATA_URL,
- publicBase: PUBLIC_BASE,
- chrome: false,
- });
+ let prepared: Awaited> | null = null;
+ for (let attempt = 1; !prepared; attempt++) {
+ await waitForCeHealthy();
+ try {
+ prepared = await prepareSession({
+ embed: true,
+ dataUrl: DATA_URL,
+ publicBase: PUBLIC_BASE,
+ chrome: false,
+ });
+ } catch (e) {
+ // A load that raced a CE restart (502) gets another go.
+ if (attempt >= BOOT_ATTEMPTS) throw e;
+ await sleep(2000);
+ }
+ }
this.modulesReady = true;
this.createHandle = prepared.createHandle ?? null;
this.ceNative = prepared.nativeRelease ?? {
diff --git a/frontend/src/widgets/embed/ceEmbedShell.html b/frontend/src/widgets/embed/ceEmbedShell.html
index 42908b8..23de209 100644
--- a/frontend/src/widgets/embed/ceEmbedShell.html
+++ b/frontend/src/widgets/embed/ceEmbedShell.html
@@ -25,7 +25,7 @@
replay ↻
- types: 4/11
+ types: 4/14
Show labels for
@@ -33,6 +33,8 @@
Analyses
Concepts
Entities
+
Procedures
+
Executions
Evidence
Facts
Figures
@@ -100,6 +102,14 @@
+
+
+
+
+
+
+
+
diff --git a/frontend/src/widgets/embed/ceHostBridge.test.ts b/frontend/src/widgets/embed/ceHostBridge.test.ts
new file mode 100644
index 0000000..fbfe258
--- /dev/null
+++ b/frontend/src/widgets/embed/ceHostBridge.test.ts
@@ -0,0 +1,72 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+// ceHostBridge reads `window` lazily; give node a minimal one.
+(globalThis as unknown as { window: unknown }).window = globalThis;
+
+const {
+ guardCeModal, guardCeSidebar, isCeSplitUrl, toSwitchbaySplitBody,
+} = await import("./ceHostBridge.ts");
+
+test("CE modal bodies are sanitized on open (init, refresh and re-open)", () => {
+ const rendered: string[] = [];
+ const pages: Record
= {};
+ const modal = {
+ init(data: { pages: typeof pages }) { Object.assign(pages, data.pages); },
+ refresh(data: { pages: typeof pages }) { Object.assign(pages, data.pages); },
+ open(id: string) { rendered.push(pages[id]?.body_html ?? ""); return true; },
+ };
+ (globalThis as unknown as { Modal: typeof modal }).Modal = modal;
+ const clean = (h: string) => h.replace(/ onerror=[^>]*/g, "").replace(/' } } };
+ modal.init(data);
+ modal.open("a");
+ assert.equal(rendered[0], "x
");
+
+ const fresh = { pages: { a: { body_html: "y
" } } };
+ modal.refresh(fresh);
+ modal.open("a");
+ assert.equal(rendered[1], "y
");
+
+ // Guarding twice must not double-wrap.
+ assert.equal(guardCeModal(clean), true);
+ modal.open("a");
+ assert.equal(rendered[2], "y
");
+});
+
+test("split URL matcher covers raw and embed-prefixed paths only", () => {
+ assert.ok(isCeSplitUrl("/api/split"));
+ assert.ok(isCeSplitUrl("/embed/ce/api/split"));
+ assert.ok(isCeSplitUrl("http://127.0.0.1:8815/embed/ce/api/split"));
+ assert.ok(!isCeSplitUrl("/api/workspaces/split"));
+ assert.ok(!isCeSplitUrl("/embed/ce/api/splitter"));
+});
+
+test("CE split body maps onto Switch Bay's workspace split", () => {
+ assert.deepEqual(
+ toSwitchbaySplitBody({ name: " team ", move: ["a", ""], copy: ["b"] }),
+ { name: "team", move: ["a"], copy: ["b"] },
+ );
+ assert.deepEqual(
+ toSwitchbaySplitBody({ target: "/home/ben/Workspaces/side-project/", move: ["x"] }),
+ { name: "side-project", move: ["x"], copy: [] },
+ );
+ assert.deepEqual(toSwitchbaySplitBody(null), { name: "", move: [], copy: [] });
+});
+
+test("CE Sidebar.setActive is a no-op when the page list isn't mounted", () => {
+ const calls: string[] = [];
+ const sidebar = {
+ setActive(id: string) {
+ if (id === "boom") throw new TypeError("listEl is undefined");
+ calls.push(id);
+ },
+ };
+ (globalThis as unknown as { Sidebar: typeof sidebar }).Sidebar = sidebar;
+ assert.equal(guardCeSidebar(), true);
+ assert.doesNotThrow(() => sidebar.setActive("boom"));
+ sidebar.setActive("ok");
+ assert.deepEqual(calls, ["ok"]);
+});
diff --git a/frontend/src/widgets/embed/ceHostBridge.ts b/frontend/src/widgets/embed/ceHostBridge.ts
new file mode 100644
index 0000000..df3b87a
--- /dev/null
+++ b/frontend/src/widgets/embed/ceHostBridge.ts
@@ -0,0 +1,365 @@
+/**
+ * Switch Bay-side shims around Curiosity Engine's embedded viewer.
+ *
+ * The Graph tab is CE's own viewer (window.CEEmbed via /embed/ce). CE's
+ * modal renders `page.body_html` with innerHTML and knows nothing about
+ * the rest of the shell, so the host adds back what the shell needs:
+ *
+ * - guardCeModal: page HTML is agent/user-authored and CE emits raw
+ * HTML through. The viewer runs on the daemon's origin (which has
+ * filesystem + shell authority), so each page body goes through
+ * DOMPurify (same policy as the Editor) before CE's modal shows it.
+ * - enhanceCeModalBody: KaTeX math, and "↗ Sheet" / "↗ Plot" buttons
+ * above each table (bridged to React via `sy:open-as-sheet` etc.).
+ * - bindSlideshowButton: "Make HTML slideshow" in the page modal.
+ * - routeCeSplit: CE's split panel POSTs /api/split to CE's own server,
+ * which only partitions files. Switch Bay's split also registers the
+ * new workspace, rebuilds both graphs, heals cross-boundary links and
+ * toasts when it's ready — so the panel's request goes there instead.
+ */
+
+import { sanitizeHtml } from "../../lib/sanitizeHtml.ts";
+
+type CePage = { id?: string; path?: string; type?: string; body_html?: unknown };
+type CeData = { pages?: Record | null };
+
+type CeModalApi = {
+ init?: (data: unknown, ...rest: unknown[]) => unknown;
+ refresh?: (data: unknown, ...rest: unknown[]) => unknown;
+ open?: (pageId: string, ...rest: unknown[]) => unknown;
+ __syGuarded?: boolean;
+};
+
+/**
+ * Sanitize page HTML right before CE's modal renders it. CE keeps the
+ * `pages` object it was given by init/refresh and reads
+ * `pages[id].body_html` on open, so wrapping those three calls covers
+ * every path (fresh fetch, revalidate, atlas cache) at one choke point,
+ * lazily — one page per open, not the whole wiki up front.
+ */
+export function guardCeModal(clean: (html: string) => string = sanitizeHtml): boolean {
+ const w = window as unknown as { Modal?: CeModalApi };
+ const modal = w.Modal;
+ if (!modal || modal.__syGuarded) return !!modal;
+ let pages: Record = {};
+ const remember = (data: unknown) => {
+ const p = (data as CeData | null)?.pages;
+ pages = p && typeof p === "object" ? p : {};
+ };
+ const origInit = modal.init;
+ const origRefresh = modal.refresh;
+ const origOpen = modal.open;
+ if (typeof origInit === "function") {
+ modal.init = (data, ...rest) => {
+ remember(data);
+ return origInit.call(modal, data, ...rest);
+ };
+ }
+ if (typeof origRefresh === "function") {
+ modal.refresh = (data, ...rest) => {
+ remember(data);
+ return origRefresh.call(modal, data, ...rest);
+ };
+ }
+ if (typeof origOpen === "function") {
+ modal.open = (pageId, ...rest) => {
+ const page = pages[pageId];
+ if (page && !page.__sySafe && typeof page.body_html === "string") {
+ page.body_html = clean(page.body_html);
+ page.__sySafe = true;
+ }
+ return origOpen.call(modal, pageId, ...rest);
+ };
+ }
+ modal.__syGuarded = true;
+ return true;
+}
+
+/**
+ * CE's page list helpers assume the list is mounted. In Zen (or before
+ * the sidebar mounts) CE still calls `Sidebar.setActive` on every
+ * `#page=` change and throws, which skips the node focus after it.
+ * Make those two calls safe no-ops when the list isn't there.
+ */
+export function guardCeSidebar(): boolean {
+ type SidebarApi = {
+ setActive?: (...a: unknown[]) => unknown;
+ setSearchHits?: (...a: unknown[]) => unknown;
+ __syGuarded?: boolean;
+ };
+ const sb = (window as unknown as { Sidebar?: SidebarApi }).Sidebar;
+ if (!sb || sb.__syGuarded) return !!sb;
+ for (const key of ["setActive", "setSearchHits"] as const) {
+ const orig = sb[key];
+ if (typeof orig !== "function") continue;
+ sb[key] = (...args: unknown[]) => {
+ try {
+ return orig.apply(sb, args);
+ } catch {
+ return undefined;
+ }
+ };
+ }
+ sb.__syGuarded = true;
+ return true;
+}
+
+// ── Split: CE panel → Switch Bay workspace split ────────────────────
+
+/** True for CE's split POST (raw, or already rewritten onto /embed/ce). */
+export function isCeSplitUrl(url: string, publicBase = "/embed/ce"): boolean {
+ let path = url;
+ try {
+ path = new URL(url, "http://sy.local").pathname;
+ } catch {
+ /* keep raw */
+ }
+ const base = publicBase.replace(/\/$/, "");
+ return path === "/api/split" || path === `${base}/api/split`;
+}
+
+/** Map CE's split body onto Switch Bay's `/api/workspaces/split` body. */
+export function toSwitchbaySplitBody(raw: unknown): { name: string; move: string[]; copy: string[] } {
+ const b = (raw && typeof raw === "object" ? raw : {}) as {
+ name?: unknown; target?: unknown; move?: unknown; copy?: unknown;
+ };
+ const list = (v: unknown) => (Array.isArray(v) ? v.map((x) => String(x)).filter(Boolean) : []);
+ let name = typeof b.name === "string" ? b.name.trim() : "";
+ if (!name && typeof b.target === "string") {
+ name = b.target.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
+ }
+ return { name, move: list(b.move), copy: list(b.copy) };
+}
+
+/**
+ * Route CE's split panel to Switch Bay's split. Install after the embed
+ * fetch shim; the shim's release restores the original fetch (dropping
+ * this wrapper with it).
+ */
+export function routeCeSplit(publicBase = "/embed/ce"): void {
+ const w = window as unknown as { fetch: typeof fetch };
+ if ((w.fetch as unknown as { __sySplit?: boolean }).__sySplit) return;
+ const inner = w.fetch.bind(window);
+ const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = typeof input === "string"
+ ? input
+ : input instanceof URL ? input.href : (input as Request).url;
+ const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
+ if (method !== "POST" || !isCeSplitUrl(url, publicBase)) return inner(input as RequestInfo, init);
+ let raw: unknown = {};
+ try {
+ raw = JSON.parse(typeof init?.body === "string" ? init.body : "{}");
+ } catch { /* empty body → validation error below */ }
+ const body = toSwitchbaySplitBody(raw);
+ const res = await inner("/api/workspaces/split", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ const out = await res.json().catch(() => ({} as Record));
+ const payload = res.ok
+ ? {
+ ...out,
+ // CE's panel reports "Done → target (n moved, m copied)"; Switch Bay
+ // builds in the background and toasts when the workspace is ready.
+ target: `${out.target ?? body.name} — building in the background`,
+ moved: body.move.length,
+ copied: body.copy.length,
+ }
+ : out;
+ return new Response(JSON.stringify(payload), {
+ status: res.status,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+ (wrapped as unknown as { __sySplit?: boolean }).__sySplit = true;
+ w.fetch = wrapped as typeof fetch;
+}
+
+// ── Modal body enhancements ─────────────────────────────────────────
+
+type MathRenderer = (el: HTMLElement, opts: unknown) => void;
+let mathRenderer: MathRenderer | null = null;
+let mathLoading: Promise | null = null;
+
+function loadMath(): Promise {
+ if (mathRenderer) return Promise.resolve(mathRenderer);
+ if (!mathLoading) {
+ mathLoading = Promise.all([
+ // @ts-expect-error — KaTeX's auto-render ships without types.
+ import("katex/contrib/auto-render"),
+ import("katex/dist/katex.min.css"),
+ ])
+ .then(([mod]) => {
+ mathRenderer = ((mod as { default?: MathRenderer }).default
+ ?? (mod as unknown as MathRenderer));
+ return mathRenderer;
+ })
+ .catch(() => null);
+ }
+ return mathLoading;
+}
+
+const MATH_HINT = /\$|\\\(|\\\[/;
+
+function renderMath(body: HTMLElement): void {
+ if (!MATH_HINT.test(body.textContent || "")) return;
+ void loadMath().then((render) => {
+ if (!render || !body.isConnected) return;
+ try {
+ render(body, {
+ delimiters: [
+ { left: "$$", right: "$$", display: true },
+ { left: "\\[", right: "\\]", display: true },
+ { left: "$", right: "$", display: false },
+ { left: "\\(", right: "\\)", display: false },
+ ],
+ throwOnError: false,
+ ignoredTags: ["script", "noscript", "style", "textarea", "pre", "code"],
+ });
+ } catch (e) {
+ console.warn("[ce-modal] KaTeX render failed", e);
+ }
+ });
+}
+
+export function parseTableValues(table: HTMLTableElement): (string | number | null)[][] {
+ const rows: (string | number | null)[][] = [];
+ const cellValue = (c: HTMLTableCellElement): string | number | null => {
+ const raw = (c.textContent || "").trim();
+ if (raw === "") return null;
+ const n = Number(raw.replace(/,/g, ""));
+ return Number.isFinite(n) && /^-?[\d,.]+(e-?\d+)?$/i.test(raw) ? n : raw;
+ };
+ for (const tr of Array.from(table.rows)) {
+ rows.push(Array.from(tr.cells).map(cellValue));
+ }
+ return rows.filter((r) => r.some((v) => v !== null));
+}
+
+function tableButton(label: string, title: string, onClick: (btn: HTMLButtonElement) => void) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "sy-mdview-table-linkout";
+ b.textContent = label;
+ b.title = title;
+ b.addEventListener("click", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ onClick(b);
+ });
+ return b;
+}
+
+async function plotFromTable(origin: string, values: (string | number | null)[][], btn: HTMLButtonElement) {
+ btn.disabled = true;
+ const toPlots = () =>
+ window.dispatchEvent(new CustomEvent("sy:switch-tab-kind", { detail: { kind: "vega" } }));
+ try {
+ // Re-click on a table that already has plots just opens them.
+ try {
+ const list = await fetch("/api/plots").then((r) => (r.ok ? r.json() : null));
+ const plots = (list && Array.isArray(list.plots) ? list.plots : []) as Array<{ origin?: string }>;
+ if (plots.some((p) => p && p.origin === origin)) {
+ toPlots();
+ return;
+ }
+ } catch { /* fall through */ }
+ const body = await fetch("/api/plots/from-table", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ origin, values }),
+ }).then((r) => r.json());
+ if (body && body.run_id) {
+ window.dispatchEvent(new CustomEvent("sy:rail-system-tip", {
+ detail: {
+ text:
+ "Plotting from `" + origin + "` — the agent is authoring 2-4 "
+ + "Vega-Lite plots from the table. Watch them land in the Plot "
+ + "tab, or open the Agents tab to follow the transcript (run `"
+ + body.run_id + "`).",
+ focus: false,
+ },
+ }));
+ toPlots();
+ }
+ } catch (e) {
+ console.warn("[ce-modal] plot from table failed", e);
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+function attachTableLinkouts(body: HTMLElement, originHint: string): void {
+ body.querySelectorAll(".sy-mdview-table-linkout-row").forEach((n) => n.remove());
+ body.querySelectorAll("table").forEach((table, i) => {
+ const origin = `${originHint || "graph-modal"}#table-${i + 1}`;
+ const row = document.createElement("div");
+ row.className = "sy-mdview-table-linkout-row";
+ row.appendChild(tableButton("↗ Sheet", "Open this table in the Sheet tab for editing", () => {
+ const values = parseTableValues(table);
+ if (!values.length) return;
+ window.dispatchEvent(new CustomEvent("sy:open-as-sheet", { detail: { origin, values } }));
+ }));
+ row.appendChild(tableButton("↗ Plot", "Ask the agent to author Vega-Lite plots from this table", (btn) => {
+ const values = parseTableValues(table);
+ if (values.length) void plotFromTable(origin, values, btn);
+ }));
+ table.parentNode?.insertBefore(row, table);
+ });
+}
+
+/** Page id currently shown in CE's modal (from the URL hash CE keeps). */
+export function currentModalPageId(): string | null {
+ const m = window.location.hash.match(/^#page=([^&]+)$/);
+ return m ? decodeURIComponent(m[1]) : null;
+}
+
+/** Decorate a freshly rendered CE modal body (idempotent per render). */
+export function enhanceCeModalBody(body: HTMLElement, originHint: string): void {
+ renderMath(body);
+ attachTableLinkouts(body, originHint);
+}
+
+const SLIDES_HIDDEN_TYPES = new Set(["figure", "table", "source", "sources"]);
+
+/**
+ * "Make HTML slideshow" in CE's modal. The button lives in Switch Bay's
+ * canvas shell markup (ceEmbedShell.html); bind it once, then show it
+ * for every page type except figure/table/source stubs.
+ */
+export function bindSlideshowButton(
+ modal: HTMLElement,
+ pagePath: () => string | null,
+ pageType: () => string,
+): void {
+ const btn = modal.querySelector("#modal-slides");
+ if (!btn) return;
+ if (btn.dataset.syBound !== "1") {
+ btn.dataset.syBound = "1";
+ btn.addEventListener("click", async () => {
+ let path = pagePath();
+ if (!path) return;
+ if (!path.startsWith("wiki/")) path = `wiki/${path}`;
+ btn.disabled = true;
+ try {
+ const r = await fetch("/api/slideshows/from-md", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path, open: true, generate_media: false }),
+ });
+ const out = r.ok ? await r.json() : null;
+ if (out && out.slug) {
+ window.dispatchEvent(new CustomEvent("sy:open-as-slideshow", {
+ detail: { slug: out.slug, title: out.title || out.slug },
+ }));
+ }
+ } catch (e) {
+ console.warn("[ce-modal] slideshow failed", e);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ }
+ btn.style.display = SLIDES_HIDDEN_TYPES.has(pageType()) ? "none" : "";
+}
diff --git a/frontend/src/widgets/embed/ceSidebarHeader.test.ts b/frontend/src/widgets/embed/ceSidebarHeader.test.ts
new file mode 100644
index 0000000..002f32c
--- /dev/null
+++ b/frontend/src/widgets/embed/ceSidebarHeader.test.ts
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+import { renderSidebarShell, workspaceLabel } from "./ceSidebarHeader.ts";
+
+const shell = readFileSync(new URL("./ceSidebarShell.html", import.meta.url), "utf8");
+
+function headerName(html: string): string {
+ const m = html.match(/([^<]*)<\/span>/);
+ assert.ok(m, "sidebar shell keeps a .workspace-name header");
+ return m[1];
+}
+
+test("sidebar shell still carries the {{WORKSPACE}} placeholder CE uses", () => {
+ assert.match(shell, /\{\{WORKSPACE\}\}/);
+});
+
+test("mounted header shows the workspace name, never the raw placeholder", () => {
+ const html = renderSidebarShell(shell, { workspace: "/Users/ben/Workspaces/ml-walkthrough" });
+ assert.equal(headerName(html), "ml-walkthrough");
+ assert.doesNotMatch(html, /\{\{WORKSPACE\}\}/);
+});
+
+test("trailing slashes and Windows paths resolve to the folder name", () => {
+ assert.equal(workspaceLabel({ workspace: "/tmp/ws/research/" }), "research");
+ assert.equal(workspaceLabel({ workspace: "C:\\Users\\ben\\notes" }), "notes");
+});
+
+test("missing workspace falls back to a neutral label, not the placeholder", () => {
+ for (const data of [null, {}, { workspace: "" }, { workspace: 42 }]) {
+ const html = renderSidebarShell(shell, data);
+ assert.equal(headerName(html), "workspace");
+ assert.doesNotMatch(html, /\{\{WORKSPACE\}\}/);
+ }
+});
+
+test("workspace names are inserted as text, not markup", () => {
+ const html = renderSidebarShell(shell, { workspace: "/tmp/x " });
+ assert.doesNotMatch(html, /x/);
+ assert.doesNotMatch(html, /\{\{WORKSPACE\}\}/);
+});
diff --git a/frontend/src/widgets/embed/ceSidebarHeader.ts b/frontend/src/widgets/embed/ceSidebarHeader.ts
new file mode 100644
index 0000000..8675147
--- /dev/null
+++ b/frontend/src/widgets/embed/ceSidebarHeader.ts
@@ -0,0 +1,34 @@
+/**
+ * CE sidebar header: Switch Bay's sidebar shell (ceSidebarShell.html)
+ * carries a literal `{{WORKSPACE}}` in `.workspace-name` — the same
+ * placeholder CE's own index.html uses. Substitute it from data.json's
+ * `workspace` (last path segment) before the markup reaches the DOM so
+ * the header never shows the raw placeholder.
+ */
+
+export const WORKSPACE_PLACEHOLDER = /\{\{WORKSPACE\}\}/g;
+
+export function workspaceLabel(data: unknown): string {
+ if (data && typeof data === "object" && "workspace" in data) {
+ const w = (data as { workspace?: unknown }).workspace;
+ if (typeof w === "string" && w.trim()) {
+ const parts = w.split(/[\\/]/).filter(Boolean);
+ return parts[parts.length - 1] || w;
+ }
+ }
+ return "workspace";
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/** Sidebar shell markup with the workspace name filled in. */
+export function renderSidebarShell(html: string, data: unknown): string {
+ const label = escapeHtml(workspaceLabel(data));
+ return html.replace(WORKSPACE_PLACEHOLDER, () => label);
+}
diff --git a/frontend/src/widgets/embed/ceSidebarShell.html b/frontend/src/widgets/embed/ceSidebarShell.html
index 41ed566..080f67c 100644
--- a/frontend/src/widgets/embed/ceSidebarShell.html
+++ b/frontend/src/widgets/embed/ceSidebarShell.html
@@ -18,7 +18,7 @@
-
`;
-
-/** CE's sidebar HTML — now mounted in Switch Bay's Browser column.
- * See sidebar/Sidebar.tsx. */
-export const sidebarTemplate = /* html */ `
-
-`;
diff --git a/frontend/src/zen/ZenBrowserTab.tsx b/frontend/src/zen/ZenBrowserTab.tsx
index 65cc121..7a9d42a 100644
--- a/frontend/src/zen/ZenBrowserTab.tsx
+++ b/frontend/src/zen/ZenBrowserTab.tsx
@@ -1,8 +1,8 @@
-import { useState } from "react";
import type { GraphData } from "../widgets/graph/types";
import FileBrowser from "../sidebar/FileBrowser";
import SourceBrowser from "../sidebar/SourceBrowser";
-import WikiPane from "../sidebar/WikiPane";
+import CeSidebarSlot from "../widgets/embed/CeSidebarSlot";
+import { useCeGraph } from "../widgets/embed/useProxiedSkillEmbeds";
import { useIngestDrop } from "../sidebar/ingestDrop";
/**
@@ -23,17 +23,18 @@ type Props = {
filesVersion: number;
};
-export default function ZenBrowserTab({ data, error, filesVersion }: Props) {
- const [refreshKey, setRefreshKey] = useState(0);
+export default function ZenBrowserTab({ error, filesVersion }: Props) {
const { uploading, dragOver, dropProps } = useIngestDrop();
- const key = refreshKey + filesVersion;
+ const ce = useCeGraph();
+ const showWiki = !!ce?.installed && ce.hasWiki;
+ const key = filesVersion;
return (
-
+ {showWiki &&
WIKI
{uploading && (
@@ -51,13 +52,10 @@ export default function ZenBrowserTab({ data, error, filesVersion }: Props) {
) : (
- setRefreshKey((k) => k + 1)}
- />
+
)}
-
+
}
diff --git a/frontend/src/zen/ZenShell.tsx b/frontend/src/zen/ZenShell.tsx
index d31b7fc..c0b613b 100644
--- a/frontend/src/zen/ZenShell.tsx
+++ b/frontend/src/zen/ZenShell.tsx
@@ -3,7 +3,7 @@ import type { TabSpec, Workspaces } from "../ws";
import type { GraphData } from "../widgets/graph/types";
import type { TerminalWsApi } from "../rail/PtyThreadSurface";
import type { ActiveRun } from "../center/activeRun";
-import GraphTab from "../widgets/graph/GraphTab";
+import CeGraphSurface from "../widgets/embed/CeGraphSurface";
import ZenSurfaceHost, { type ZenArtifact } from "./ZenSurfaceHost";
import ZenChatBox from "./ZenChatBox";
import Rail, { type RailEntry } from "../rail/Rail";
@@ -17,8 +17,8 @@ import WorkspaceSwitcher from "../layout/WorkspaceSwitcher";
/**
* Zen mode (charter, designed 2026-07-05): a minimal alternate shell —
- * three parts only. Left = the graph view, always (same GraphTab, no
- * tab header). Right = every other surface, one at a time, behind a
+ * three parts only. Left = the graph view, always (Curiosity Engine's
+ * viewer, same as Power's Graph tab, no tab header). Right = every other surface, one at a time, behind a
* dropdown. A draggable central divider resizes them (full-range;
* double-click resets 50/50). The chat box floats at the bottom over
* both panes; chrome affordances float too — faint until hovered.
@@ -198,10 +198,8 @@ export default function ZenShell({
{/* Key by workspace: a switch fully remounts the CE graph so
nothing from the previous workspace lingers (same contract
as Power's CenterColumn). */}
-
diff --git a/frontend/tests/e2e/atlas-search.spec.ts b/frontend/tests/e2e/atlas-search.spec.ts
deleted file mode 100644
index 06fd81b..0000000
--- a/frontend/tests/e2e/atlas-search.spec.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { expect, test, type Page } from "@playwright/test";
-
-test.use({
- baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173",
-});
-
-/** Capture the mounted Atlas handle so the test can read engine state
- * (selection = search rings) and use the engine's own hit tester to
- * find a pixel that is provably empty. */
-async function captureAtlasHandle(page: Page): Promise
{
- await page.addInitScript(() => {
- const capture = () => {
- const atlas = (window as unknown as { KnowledgeAtlas?: { mount?: Function } }).KnowledgeAtlas;
- if (!atlas?.mount || (atlas.mount as { __captured?: boolean }).__captured) return false;
- const original = atlas.mount.bind(atlas);
- const wrapped = ((container: HTMLElement, opts: unknown) => {
- const handle = original(container, opts);
- (window as unknown as { __atlasHandle?: unknown }).__atlasHandle = handle;
- return handle;
- }) as typeof atlas.mount & { __captured?: boolean };
- wrapped.__captured = true;
- atlas.mount = wrapped;
- return true;
- };
- const id = window.setInterval(() => { if (capture()) window.clearInterval(id); }, 0);
- });
-}
-
-type Handle = {
- engine: {
- getState(): { selection?: string[]; pinned?: string[] };
- snapshot(): { scene?: { nodes: Array<{ role: string }>; edges: Array<{ priority?: number }> } | null };
- hitTester: { pointAt(x: number, y: number, slack?: number): { id: string; kind: string } | null };
- };
-};
-
-/** Atlas's own "current focus" decoration: an accent ring on one node
- * plus its edges lit at priority 1. Switch Bay strips it — the wiki
- * stays resident as one scene, so the engine never rebuilds on focus
- * and the mark sat forever on an entry node nobody picked. */
-const focusMarks = (page: Page) => page.evaluate(() => {
- const scene = (window as unknown as { __atlasHandle?: Handle })
- .__atlasHandle?.engine.snapshot().scene;
- if (!scene) return -1;
- return scene.nodes.filter((n) => n.role === "focus").length
- + scene.edges.filter((e) => e.priority === 1).length;
-});
-
-/** The dashed halo the renderer draws on search hits. */
-const haloCount = (page: Page) => page.evaluate(() => (
- (window as unknown as { __atlasHandle?: Handle }).__atlasHandle
- ?.engine.getState().pinned?.length ?? -1
-));
-
-/** Solid accent rings. Search must not add these — they competed with
- * the halo and read as a second highlight on the same nodes. */
-const selectionSize = (page: Page) => page.evaluate(() => (
- (window as unknown as { __atlasHandle?: Handle }).__atlasHandle
- ?.engine.getState().selection?.length ?? -1
-));
-
-/** Scan the canvas for a point the engine reports as empty (`want:
- * "blank"`) or as a node (`want: "node"`). */
-async function probePoint(
- page: Page, want: "blank" | "node",
-): Promise<{ x: number; y: number }> {
- const point = await page.evaluate((mode) => {
- const canvas = document.querySelector("#graph > canvas:not(.atlas-minimap)");
- const handle = (window as unknown as { __atlasHandle?: Handle }).__atlasHandle;
- if (!canvas || !handle) return null;
- const rect = canvas.getBoundingClientRect();
- for (let fy = 0.1; fy < 0.95; fy += 0.02) {
- for (let fx = 0.1; fx < 0.95; fx += 0.02) {
- const cx = rect.left + rect.width * fx;
- const cy = rect.top + rect.height * fy;
- const hit = handle.engine.hitTester.pointAt(
- cx - rect.left - rect.width / 2,
- cy - rect.top - rect.height / 2,
- 6,
- );
- const ok = mode === "node" ? hit?.kind === "node" : hit === null;
- // A blank point must have clear air around it too, so a stray
- // pixel between two nodes can't make the test flaky.
- if (!ok) continue;
- if (mode === "blank") {
- const wide = handle.engine.hitTester.pointAt(
- cx - rect.left - rect.width / 2,
- cy - rect.top - rect.height / 2,
- 24,
- );
- if (wide) continue;
- }
- return { x: cx, y: cy };
- }
- }
- return null;
- }, want);
- if (!point) throw new Error(`no ${want} point found on the atlas canvas`);
- return point;
-}
-
-test("atlas search rings clear when the search is cancelled", async ({ page }) => {
- await captureAtlasHandle(page);
- await page.goto("/?viewer=atlas");
- await expect(page.locator(".atlas-minimap:not(.classic-minimap)")).toBeVisible({ timeout: 30_000 });
-
- await page.locator("#graph-search-input").fill("attention");
- await expect(page.locator("#graph-search-count")).not.toHaveText("0");
- await expect.poll(() => haloCount(page)).toBeGreaterThan(0);
- await expect.poll(() => selectionSize(page)).toBe(0);
- await expect(page.locator("#graph")).not.toHaveAttribute("data-search-hits", "0");
-
- await page.locator("#graph-search-clear").click();
- // pin()/unpin() would ask for a scene rebuild each; a rebuild landing
- // after the clear repainted the stale halos and left them stuck on.
- await expect.poll(() => haloCount(page)).toBe(0);
- await expect(page.locator("#graph")).toHaveAttribute("data-search-hits", "0");
-});
-
-test("atlas carries no focus ring — before, during or after a search", async ({ page }) => {
- await captureAtlasHandle(page);
- await page.goto("/?viewer=atlas");
- await expect(page.locator(".atlas-minimap:not(.classic-minimap)")).toBeVisible({ timeout: 30_000 });
-
- await expect.poll(() => focusMarks(page)).toBe(0);
-
- await page.locator("#graph-search-input").fill("attention");
- await expect.poll(() => haloCount(page)).toBeGreaterThan(0);
- await expect.poll(() => focusMarks(page)).toBe(0);
- await page.locator("#graph-search-clear").click();
-
- // Opening a page focuses the engine; the mark must not reappear when
- // a scene rebuild lands afterwards.
- await page.locator(".sidebar-row").nth(4).click();
- await page.waitForTimeout(1_000);
- await page.keyboard.press("Escape");
- await expect.poll(() => focusMarks(page)).toBe(0);
-});
-
-test("atlas: clicking empty canvas opens nothing, even with a stale hover", async ({ page }) => {
- await captureAtlasHandle(page);
- await page.goto("/?viewer=atlas");
- await expect(page.locator(".atlas-minimap:not(.classic-minimap)")).toBeVisible({ timeout: 30_000 });
-
- const modal = page.locator("#modal[aria-hidden='false']");
- const canvas = page.locator("#graph > canvas:not(.atlas-minimap)");
-
- // A restored selection opens the doc modal over the canvas on load.
- if (await modal.isVisible().catch(() => false)) {
- await page.keyboard.press("Escape");
- await expect(modal).toBeHidden();
- }
-
- // Park the pointer on a node so the engine records a hover id.
- const node = await probePoint(page, "node");
- await page.mouse.move(node.x, node.y);
- await expect.poll(async () => canvas.getAttribute("data-hover-id")).not.toBe("");
-
- // Click empty canvas WITHOUT an intervening pointermove — hover only
- // updates on move, so the recorded id is still the node above. The
- // click handler used to open whatever that id was, which is how a
- // click on nothing summoned a document.
- const blank = await probePoint(page, "blank");
- await page.evaluate(({ x, y }) => {
- const el = document.querySelector("#graph > canvas:not(.atlas-minimap)");
- if (!el) throw new Error("no atlas canvas");
- for (const type of ["pointerdown", "pointerup"]) {
- el.dispatchEvent(new PointerEvent(type, {
- clientX: x, clientY: y, bubbles: true, cancelable: true, pointerId: 1,
- }));
- }
- }, blank);
- await page.waitForTimeout(500);
- await expect(modal).toBeHidden();
-
- // A real click on a node still opens it.
- await page.mouse.click(node.x, node.y);
- await expect(modal).toBeVisible();
-});
diff --git a/frontend/tests/e2e/atlas-select.spec.ts b/frontend/tests/e2e/atlas-select.spec.ts
deleted file mode 100644
index e04d636..0000000
--- a/frontend/tests/e2e/atlas-select.spec.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { expect, test, type Page } from "@playwright/test";
-
-test.use({
- baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173",
-});
-
-async function switchViewer(page: Page, mode: "atlas" | "classic"): Promise {
- const btn = page.locator("#viewer-mode");
- await expect(btn).toBeVisible({ timeout: 30_000 });
- const modal = page.locator("#modal[aria-hidden='false']");
- if (await modal.isVisible().catch(() => false)) {
- await page.keyboard.press("Escape");
- await expect(modal).toBeHidden({ timeout: 5_000 });
- }
- const state = page.locator("#viewer-mode-state");
- for (let i = 0; i < 2; i++) {
- if (((await state.textContent()) || "").trim() === mode) return;
- await btn.click();
- await page.locator(mode === "atlas" ? ".atlas-minimap:not(.classic-minimap)" : ".classic-minimap")
- .waitFor({ timeout: 20_000 });
- }
-}
-
-async function enterZen(page: Page): Promise {
- if (await page.locator(".sy-zen").isVisible().catch(() => false)) return;
- await page.getByRole("button", { name: "Toggle Power / Zen mode" }).click();
- await expect(page.locator(".sy-zen")).toBeVisible();
-}
-
-async function clickAtlasNode(page: Page): Promise {
- const canvas = page.locator("#graph > canvas:not(.atlas-minimap)");
- await expect(canvas).toBeVisible();
- const box = (await canvas.boundingBox())!;
- // Focus sits at the centre; probe nearby if the first click misses.
- const spots = [
- [0, 0], [0.08, 0], [-0.08, 0], [0, 0.08], [0, -0.08],
- [0.12, 0.08], [-0.12, -0.08],
- ];
- for (const [dx, dy] of spots) {
- const x = box.x + box.width * (0.5 + dx);
- const y = box.y + box.height * (0.5 + dy);
- await page.mouse.move(x, y);
- await page.mouse.click(x, y);
- if (await page.locator(".sy-editor-path").isVisible({ timeout: 1500 }).catch(() => false)) {
- return;
- }
- }
- throw new Error("atlas click did not open a page in the Editor");
-}
-
-test("atlas requests individual hybrid nodes on its first frame", async ({ page }) => {
- await page.addInitScript(() => {
- const capture = () => {
- const atlas = (window as unknown as { KnowledgeAtlas?: { mount?: Function } }).KnowledgeAtlas;
- if (!atlas?.mount || (atlas.mount as { __captured?: boolean }).__captured) return false;
- const original = atlas.mount.bind(atlas);
- const wrapped = ((container: HTMLElement, opts: unknown) => {
- (window as unknown as { __atlasMountOptions?: unknown }).__atlasMountOptions = opts;
- return original(container, opts);
- }) as typeof atlas.mount & { __captured?: boolean };
- wrapped.__captured = true;
- atlas.mount = wrapped;
- return true;
- };
- const id = window.setInterval(() => { if (capture()) window.clearInterval(id); }, 0);
- });
- await page.goto("/?viewer=atlas");
- await expect(page.locator(".atlas-minimap:not(.classic-minimap)")).toBeVisible({ timeout: 30_000 });
- const config = await page.evaluate(() => (
- (window as unknown as { __atlasMountOptions?: { config?: Record } })
- .__atlasMountOptions?.config
- ));
- expect(config?.layout).toBe("hybrid");
- expect(Number(config?.coreCapacity)).toBeGreaterThan(0);
- expect((config?.budget as { maxAggregates?: number })?.maxAggregates).toBe(0);
-});
-
-test("atlas node click in zen opens the Editor", async ({ page }) => {
- test.setTimeout(60_000);
- await page.goto("/");
- await page.locator(".sy-graph-host, .sy-zen").first().waitFor({ timeout: 20_000 });
- await switchViewer(page, "atlas");
- await enterZen(page);
- await clickAtlasNode(page);
- await expect(page.locator(".sy-editor-path")).toBeVisible();
- await expect(page.locator(".sy-zen-surf-pickbtn")).toContainText("Editor");
-});
diff --git a/frontend/tests/e2e/graph-ce-embed.spec.ts b/frontend/tests/e2e/graph-ce-embed.spec.ts
new file mode 100644
index 0000000..7eeda89
--- /dev/null
+++ b/frontend/tests/e2e/graph-ce-embed.spec.ts
@@ -0,0 +1,136 @@
+/**
+ * Graph = Curiosity Engine's viewer only, against the isolated mocked
+ * backend (E2E_MOCK=1; serves frontend/dist + fake /api, /ws and a stub
+ * /embed/ce). Never touches the live daemon or vault.
+ *
+ * - no CE install → Graph tab hidden; the Files uploader still works.
+ * - CE present → Graph tab shown, the CE sidebar header shows the
+ * workspace name (never the raw {{WORKSPACE}} placeholder), and the
+ * sidebar `+` opens Switch Bay's upload dialog.
+ */
+import { test, expect, type Page } from "@playwright/test";
+import { spawn, type ChildProcess } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const MOCK_WS = "/tmp/switchbay-e2e-mock-ws";
+const PORT = process.env.E2E_CE_PORT || "41767";
+const BASE = `http://127.0.0.1:${PORT}`;
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const repo = path.resolve(here, "../../..");
+const dist = path.join(repo, "frontend/dist");
+
+let mockProc: ChildProcess | null = null;
+
+async function waitHealth(url: string, timeoutMs = 20_000): Promise {
+ const t0 = Date.now();
+ while (Date.now() - t0 < timeoutMs) {
+ try {
+ const r = await fetch(`${url}/api/health`);
+ if (r.ok) {
+ const body = await r.json();
+ if (body.e2e_mock === true && body.workspace === MOCK_WS) return;
+ throw new Error("refusing non-fixture backend");
+ }
+ } catch (e) {
+ if (String(e).includes("refusing")) throw e;
+ }
+ await new Promise((r) => setTimeout(r, 150));
+ }
+ throw new Error(`mock backend did not become healthy at ${url}`);
+}
+
+test.beforeAll(async () => {
+ if (!fs.existsSync(path.join(dist, "index.html"))) {
+ throw new Error("frontend/dist missing — build before Playwright");
+ }
+ mockProc = spawn(
+ "uv",
+ [
+ "run", "--no-sync", "python",
+ path.join(here, "mock_backend.py"),
+ "--host", "127.0.0.1",
+ "--port", PORT,
+ "--dist", dist,
+ ],
+ { cwd: repo, env: { ...process.env, PYTHONPATH: path.join(repo, "src") }, stdio: "pipe" },
+ );
+ await waitHealth(BASE);
+});
+
+test.afterAll(async () => {
+ if (mockProc && mockProc.pid) mockProc.kill("SIGTERM");
+});
+
+test.use({ baseURL: BASE, viewport: { width: 1400, height: 900 } });
+test.setTimeout(60_000);
+
+async function setCe(mode: "absent" | "stub"): Promise {
+ const r = await fetch(`${BASE}/api/e2e/ce`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mode, graph_tab: true }),
+ });
+ expect(r.ok).toBe(true);
+}
+
+async function gotoPower(page: Page): Promise {
+ await page.addInitScript(() => {
+ localStorage.setItem("sy:ui-mode", "power");
+ try { navigator.serviceWorker?.getRegistrations?.().then((rs) => rs.forEach((r) => r.unregister())); } catch { /* ignore */ }
+ });
+ const resp = await page.goto(BASE + "/", { waitUntil: "domcontentloaded" });
+ expect(resp?.ok()).toBe(true);
+ await page.locator(".sy-shell").waitFor({ timeout: 15_000 });
+ const health = await page.evaluate(async () => (await fetch("/api/health")).json());
+ expect(health.e2e_mock).toBe(true);
+ expect(health.workspace).toBe(MOCK_WS);
+ await page.locator(".sy-tabstrip").first().waitFor({ timeout: 15_000 });
+}
+
+test("without Curiosity Engine the Graph tab is hidden and Files upload still works", async ({ page }) => {
+ await setCe("absent");
+ await gotoPower(page);
+ // The mode lists a Graph tab; the shell hides it because CE is absent.
+ await expect(page.getByRole("tab", { name: "Agents" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "Graph" })).toHaveCount(0);
+ await expect(page.locator(".sy-ce-sidebar-slot")).toHaveCount(0);
+
+ const upload = page.getByTestId("fb-upload-vault");
+ await expect(upload).toBeVisible();
+ await upload.click();
+ await expect(page.locator(".sy-upload-vault")).toBeVisible();
+});
+
+test("CE sidebar header shows the workspace name, never {{WORKSPACE}}", async ({ page }) => {
+ await setCe("stub");
+ await gotoPower(page);
+ await expect(page.getByRole("tab", { name: "Graph" })).toBeVisible();
+
+ const name = page.locator(".sy-ce-sidebar-root .workspace-name");
+ await expect(name).toHaveText("switchbay-e2e-mock-ws");
+ const sidebarHtml = await page.locator(".sy-ce-sidebar-root").innerHTML();
+ expect(sidebarHtml).not.toContain("{{WORKSPACE}}");
+ expect(await page.locator("body").innerText()).not.toContain("{{WORKSPACE}}");
+ await expect(page.locator(".sy-ce-sidebar-root .sidebar-row")).toHaveCount(1);
+ // Files tree stays under the CE page list.
+ await expect(page.getByTestId("fb-upload-vault")).toBeVisible();
+});
+
+test("CE sidebar + opens Switch Bay's upload dialog; Graph mounts the CE canvas", async ({ page }) => {
+ await setCe("stub");
+ await gotoPower(page);
+ await page.locator(".sy-ce-sidebar-root #sidebar-upload").click();
+ await expect(page.locator(".sy-upload-vault")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.locator(".sy-upload-vault")).toHaveCount(0);
+
+ await page.getByRole("tab", { name: "Graph" }).click();
+ await expect(page.locator(".sy-ce-atlas-embed-root #graph")).toBeAttached();
+ await expect(page.locator(".sy-graph-count")).toHaveText("1 nodes · 0 edges");
+ // Procedure / execution are offered in the label-type filter.
+ await expect(page.locator('.label-types-row[data-type="procedure"]')).toHaveCount(1);
+ await expect(page.locator('.label-types-row[data-type="execution"]')).toHaveCount(1);
+});
diff --git a/frontend/tests/e2e/graph-search.spec.ts b/frontend/tests/e2e/graph-search.spec.ts
deleted file mode 100644
index 94fd234..0000000
--- a/frontend/tests/e2e/graph-search.spec.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { expect, test, type Page } from "@playwright/test";
-
-test.use({
- baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173",
-});
-
-async function graphReady(page: Page): Promise {
- await page.locator(".sy-graph-host, .sy-zen").first().waitFor({ timeout: 20_000 });
- await page.locator("#graph-search-input").waitFor({ timeout: 20_000 });
-}
-
-test("graph search highlights nodes, pages and files; X clears", async ({ page }) => {
- await page.goto("/");
- await page.locator(".sy-tabstrip, .sy-zen").first().waitFor({ timeout: 20_000 });
- await page.getByRole("tab", { name: "Graph" }).click();
- await graphReady(page);
-
- const input = page.locator("#graph-search-input");
- await expect(input).toBeVisible();
- await expect(page.locator(".graph-controls")).toBeVisible();
-
- const controls = page.locator(".graph-controls");
- const searchBox = page.locator(".graph-search");
- const cBox = (await controls.boundingBox())!;
- const sBox = (await searchBox.boundingBox())!;
- expect(sBox.y).toBeLessThan(cBox.y);
-
- // The camera must not jump on search — the auto-fit-to-hits zoom was
- // removed; hits are found by their ring, not by being flown to. The
- // layout keeps nudging the view while the simulation settles, so
- // compare against that drift, not for an exact match.
- const viewportTransform = async () => {
- const raw = await page.locator("#graph svg g.viewport")
- .evaluate((el) => el.getAttribute("transform") ?? "");
- const m = /translate\(([-\d.]+),\s*([-\d.]+)\)\s*scale\(([-\d.]+)\)/.exec(raw);
- return m
- ? { x: Number(m[1]), y: Number(m[2]), k: Number(m[3]) }
- : { x: 0, y: 0, k: 1 };
- };
- // Labels are the user's setting (auto/on/off + type filter), not the
- // search's to override: naming every hit at once buried the canvas in
- // overlapping text. A search may dim labels, never add them.
- const litLabels = () => page.locator(".node-label").evaluateAll((els) =>
- els.filter((el) => parseFloat(getComputedStyle(el).opacity) > 0.9).length);
-
- await page.waitForTimeout(1500); // let the force layout settle
- const beforeZoom = await viewportTransform();
- const idleLabels = await litLabels();
-
- await input.fill("attention");
- await expect(page.locator("#graph-search-clear")).toBeVisible();
- await expect(page.locator("#graph-search-count")).not.toHaveText("0");
- await expect.poll(async () =>
- page.locator(".node.search-hit").count(),
- ).toBeGreaterThan(0);
- // Atlas-parity halo, drawn only on hits.
- await expect.poll(async () =>
- page.locator(".node.search-hit circle.search-ring").count(),
- ).toBeGreaterThan(0);
- // …and the halo is the ONLY mark: accent-striping every edge that
- // touches a hit buried the graph in green lines on a broad query.
- await expect(page.locator('.edge[data-vis="focus"], .edge[data-vis="neighbour"]'))
- .toHaveCount(0);
- expect(await litLabels()).toBeLessThanOrEqual(idleLabels);
- // Hovering a hit is how you read its name.
- const hit = (await page.locator(".node.search-hit").first().boundingBox())!;
- await page.mouse.move(hit.x + hit.width / 2, hit.y + hit.height / 2);
- await expect.poll(litLabels).toBeGreaterThan(0);
- await page.mouse.move(hit.x, hit.y + 260);
-
- await page.waitForTimeout(600); // an auto-fit transition would have landed
- const afterZoom = await viewportTransform();
- expect(Math.abs(afterZoom.k - beforeZoom.k)).toBeLessThan(0.05);
- expect(Math.hypot(afterZoom.x - beforeZoom.x, afterZoom.y - beforeZoom.y)).toBeLessThan(60);
-
- // Both browsers mark the same hits: wiki page list and file tree.
- await expect.poll(async () =>
- page.locator('.sidebar-row[data-search-hit="true"]').count(),
- ).toBeGreaterThan(0);
- // Matched pages are revealed in the file tree (their dirs auto-expand).
- const fileHit = page.locator('.sy-fb-row--search-hit[data-fb-path^="wiki/"]').first();
- await expect(fileHit).toBeVisible();
-
- await fileHit.click();
- await page.getByRole("tab", { name: "Graph" }).click();
- await graphReady(page);
- await expect(page.locator("#graph-search-input")).toHaveValue("attention");
- await expect(page.locator("#graph-search-clear")).toBeVisible();
- await expect(page.locator("#graph-search-count")).not.toHaveText("0");
- await expect.poll(async () =>
- page.locator(".sy-fb-row--search-hit").count(),
- ).toBeGreaterThan(0);
-
- await page.locator("#graph-search-clear").click();
- await expect(page.locator("#graph-search-clear")).toBeHidden();
- await expect(page.locator(".sy-fb-row--search-hit")).toHaveCount(0);
- await expect(page.locator('.sidebar-row[data-search-hit="true"]')).toHaveCount(0);
- await expect(page.locator(".node.search-hit")).toHaveCount(0);
- await expect(input).toHaveValue("");
-});
diff --git a/frontend/tests/e2e/minimap-zoom.spec.ts b/frontend/tests/e2e/minimap-zoom.spec.ts
deleted file mode 100644
index 4b40292..0000000
--- a/frontend/tests/e2e/minimap-zoom.spec.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { expect, test, type Locator, type Page } from "@playwright/test";
-
-test.use({
- baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173",
-});
-
-async function graphReady(page: Page): Promise {
- await page.locator(".sy-graph-host, .sy-zen").first().waitFor({ timeout: 20_000 });
- const map = page.locator(".atlas-minimap, .classic-minimap").first();
- await expect(map).toBeVisible({ timeout: 30_000 });
- return map;
-}
-
-async function cameraScale(map: Locator): Promise {
- return map.evaluate((el) => Number((el as HTMLElement).dataset.cameraScale || "1"));
-}
-
-async function wheelOnMap(page: Page, map: Locator): Promise {
- const box = (await map.boundingBox())!;
- await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
- await page.mouse.wheel(0, -400);
- await page.waitForTimeout(80);
-}
-
-async function dismissDocModal(page: Page): Promise {
- const modal = page.locator("#modal[aria-hidden='false']");
- if (await modal.isVisible().catch(() => false)) {
- await page.keyboard.press("Escape");
- await expect(modal).toBeHidden({ timeout: 5_000 });
- }
-}
-
-async function switchViewer(page: Page, mode: "atlas" | "classic"): Promise {
- const btn = page.locator("#viewer-mode");
- await expect(btn).toBeVisible();
- await dismissDocModal(page);
- const state = page.locator("#viewer-mode-state");
- for (let i = 0; i < 2; i++) {
- if (((await state.textContent()) || "").trim() === mode) return;
- await btn.click();
- await page.locator(mode === "atlas" ? ".atlas-minimap:not(.classic-minimap)" : ".classic-minimap")
- .waitFor({ timeout: 20_000 });
- }
-}
-
-test("atlas minimap wheel zooms the main view", async ({ page }) => {
- await page.goto("/");
- await graphReady(page);
- await switchViewer(page, "atlas");
- const map = page.locator(".atlas-minimap:not(.classic-minimap)");
- await expect(map).toBeVisible();
- const before = await cameraScale(map);
- await wheelOnMap(page, map);
- await expect.poll(async () => cameraScale(map)).not.toBeCloseTo(before, 4);
-});
-
-test("classic minimap wheel still zooms", async ({ page }) => {
- await page.goto("/");
- await graphReady(page);
- await switchViewer(page, "classic");
- const map = page.locator(".classic-minimap");
- await expect(map).toBeVisible();
- const before = await cameraScale(map);
- await wheelOnMap(page, map);
- await expect.poll(async () => cameraScale(map)).not.toBeCloseTo(before, 4);
-});
-
-test("zen lift follows float vs tab; atlas wheel still works", async ({ page }) => {
- test.setTimeout(60_000);
- await page.goto("/");
- await graphReady(page);
- await switchViewer(page, "atlas");
- await page.getByRole("button", { name: "Toggle Power / Zen mode" }).click();
- await expect(page.locator(".sy-zen")).toBeVisible();
- const map = page.locator(".atlas-minimap:not(.classic-minimap)");
- await expect(map).toBeVisible();
-
- const floating = page.locator(".sy-zen-chatbox").first();
- await expect(floating).toBeVisible();
- const zen = page.locator(".sy-zen");
- const lifted = await zen.evaluate((el) => getComputedStyle(el).getPropertyValue("--sy-minimap-bottom").trim());
- expect(parseFloat(lifted || "12")).toBeGreaterThan(12);
-
- const mapBottom = await map.evaluate((el) => parseFloat(getComputedStyle(el).bottom));
- expect(mapBottom).toBeGreaterThan(12);
-
- const before = await cameraScale(map);
- await wheelOnMap(page, map);
- await expect.poll(async () => cameraScale(map)).not.toBeCloseTo(before, 4);
-
- await page.getByRole("button", { name: "⇲ tab" }).click();
- await expect(page.locator(".sy-zen-chatbox--docked")).toBeVisible();
- const docked = await zen.evaluate((el) => getComputedStyle(el).getPropertyValue("--sy-minimap-bottom").trim());
- expect(parseFloat(docked || "12")).toBeLessThanOrEqual(12);
-
- const afterDock = await cameraScale(map);
- await wheelOnMap(page, map);
- await expect.poll(async () => cameraScale(map)).not.toBeCloseTo(afterDock, 4);
-});
diff --git a/frontend/tests/e2e/mock_backend.py b/frontend/tests/e2e/mock_backend.py
index 2cb40ba..40eaebd 100644
--- a/frontend/tests/e2e/mock_backend.py
+++ b/frontend/tests/e2e/mock_backend.py
@@ -24,6 +24,11 @@
"fail_save": False,
"clients": set(),
"desk_max_live_workers": 8,
+ # Curiosity Engine embed fixture: "absent" (no CE install) or "stub"
+ # (a tiny fake CEEmbed under /embed/ce). graph_tab adds a Graph tab
+ # to the mode so the shell's hide-without-CE rule can be observed.
+ "ce": "absent",
+ "graph_tab": False,
"comms": [
{
"key": "gmail:acct:thread-e2e",
@@ -104,7 +109,9 @@ def _hello() -> dict[str, Any]:
{"id": "wiki", "title": "Wiki", "kind": "wiki", "source": "core"},
{"id": "agents", "title": "Agents", "kind": "agents", "source": "system"},
{"id": "comms", "title": "Comms", "kind": "comms", "source": "user"},
- ],
+ ] + ([
+ {"id": "graph", "title": "Graph", "kind": "graph", "source": "core"},
+ ] if STATE["graph_tab"] else []),
},
"selection": None,
"workspaces": {
@@ -266,6 +273,11 @@ async def handle_settings_get(_request: web.Request) -> web.Response:
"admin_ceiling": None,
"chief_counted": True,
"note": "Total live seats per desk, including the chief-of-staff.",
+ "proxied_skill_embeds": False,
+ "ce_graph": {
+ "installed": STATE["ce"] == "stub",
+ "has_wiki": STATE["ce"] == "stub",
+ },
})
@@ -463,6 +475,67 @@ async def handle_push(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
+async def handle_ce_mode(request: web.Request) -> web.Response:
+ try:
+ body = await request.json()
+ except json.JSONDecodeError:
+ body = {}
+ mode = str((body or {}).get("mode") or "absent")
+ STATE["ce"] = mode if mode in ("absent", "stub") else "absent"
+ STATE["graph_tab"] = bool((body or {}).get("graph_tab"))
+ return web.json_response({"ok": True, "ce": STATE["ce"], "graph_tab": STATE["graph_tab"]})
+
+
+# A stand-in for CE's embed.js: enough of CEEmbed.create for Switch Bay's
+# dual-mount session (sidebar shell + canvas shell are Switch Bay markup).
+_CE_EMBED_STUB = """
+(function () {
+ window.CEEmbed = {
+ create: function (opts) {
+ return fetch(opts.dataUrl).then(function (r) { return r.json(); }).then(function (data) {
+ var live = false;
+ return {
+ getData: function () { return data; },
+ mountSidebar: function (el) {
+ var list = el.querySelector('#sidebar-list');
+ if (list) list.innerHTML = '';
+ },
+ mountCanvas: function () { live = true; },
+ unmountCanvas: function (o) { if (o && o.destroy) live = false; },
+ isCanvasLive: function () { return live; },
+ revalidate: function () {},
+ destroy: function () { live = false; },
+ };
+ });
+ },
+ };
+})();
+"""
+
+
+async def handle_ce_static(request: web.Request) -> web.Response:
+ if STATE["ce"] != "stub":
+ return web.Response(status=502, text="CE viewer not running")
+ name = request.match_info.get("name", "")
+ if name.endswith(".css"):
+ return web.Response(text="/* stub */", content_type="text/css")
+ body = _CE_EMBED_STUB if name == "embed.js" else "/* stub */"
+ return web.Response(text=body, content_type="application/javascript")
+
+
+async def handle_ce_data(_request: web.Request) -> web.Response:
+ if STATE["ce"] != "stub":
+ return web.Response(status=502, text="CE viewer not running")
+ return web.json_response({
+ "workspace": STATE["workspace"],
+ "palette": {},
+ "nodes": [{"id": "p1", "path": "wiki/concepts/p1.md", "title": "Page one", "type": "concept"}],
+ "edges": [],
+ "pages": {"p1": {"id": "p1", "path": "wiki/concepts/p1.md", "title": "Page one",
+ "type": "concept", "body_html": "one
"}},
+ })
+
+
def build_app(dist: pathlib.Path) -> web.Application:
app = web.Application()
app["dist"] = dist
@@ -488,6 +561,9 @@ async def handle_index(_request: web.Request) -> web.StreamResponse:
app.router.add_get("/api/graph/data", handle_graph)
app.router.add_post("/api/e2e/fail-save", handle_fail_save)
app.router.add_post("/api/e2e/push", handle_push)
+ app.router.add_post("/api/e2e/ce", handle_ce_mode)
+ app.router.add_get("/embed/ce/data.json", handle_ce_data)
+ app.router.add_get("/embed/ce/static/{name:.*}", handle_ce_static)
app.router.add_get("/ws", handle_ws)
app.router.add_route("GET", "/api/{tail:.*}", handle_json)
app.router.add_route("POST", "/api/{tail:.*}", handle_json)
diff --git a/pyproject.toml b/pyproject.toml
index 6668ad6..4ca8531 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "switchbay"
-version = "0.13.2"
+version = "0.14.0"
description = "Local single-user workbench over knowledge bases — a driven second brain."
requires-python = ">=3.11"
authors = [{ name = "Benjamin Smith" }]
diff --git a/src/switchbay/__init__.py b/src/switchbay/__init__.py
index 554c220..fd95185 100644
--- a/src/switchbay/__init__.py
+++ b/src/switchbay/__init__.py
@@ -4,7 +4,7 @@
# checker both read this, so drift here makes a current install look
# stale and offers an "update" to a release it is already past.
# tests/unit/test_version_sync.py fails the build if the two diverge.
-__version__ = "0.13.2"
+__version__ = "0.14.0"
# Use the OS certificate store for HTTPS (corporate TLS proxies, custom
# CAs). Must run before any aiohttp ClientSession creates an SSL context
diff --git a/src/switchbay/ce_viewer_supervisor.py b/src/switchbay/ce_viewer_supervisor.py
index e65d627..2663fdd 100644
--- a/src/switchbay/ce_viewer_supervisor.py
+++ b/src/switchbay/ce_viewer_supervisor.py
@@ -188,9 +188,21 @@ def _public_base() -> str:
def bundle_ready(workspace: Path) -> bool:
- """True when wiki-view cache has enough to serve without a full build."""
+ """True when the wiki-view cache can be served as is, without a build.
+
+ The bundle must also be at least as new as the wiki: a workspace that
+ changed while another one was active would otherwise come back with a
+ stale Graph after a switch.
+ """
out = cebridge.output_dir(Path(workspace))
- return (out / "index.html").is_file() and (out / "data.json").is_file()
+ data = out / "data.json"
+ if not ((out / "index.html").is_file() and data.is_file()):
+ return False
+ try:
+ built = data.stat().st_mtime
+ except OSError:
+ return False
+ return _wiki_mtime(Path(workspace)) <= built
def _spawn_argv(workspace: Path, port: int) -> tuple[list[str], str]:
@@ -434,14 +446,18 @@ def _wiki_mtime(workspace: Path) -> float:
wiki = Path(workspace) / "wiki"
if not wiki.is_dir():
return 0.0
- newest = 0.0
+ # Directories count too: deleting or renaming a page only touches
+ # its folder's mtime.
+ try:
+ newest = wiki.stat().st_mtime
+ except OSError:
+ newest = 0.0
try:
for p in wiki.rglob("*"):
- if p.is_file():
- try:
- newest = max(newest, p.stat().st_mtime)
- except OSError:
- continue
+ try:
+ newest = max(newest, p.stat().st_mtime)
+ except OSError:
+ continue
except OSError:
return newest
return newest
diff --git a/src/switchbay/cebridge.py b/src/switchbay/cebridge.py
index bda908a..57431aa 100644
--- a/src/switchbay/cebridge.py
+++ b/src/switchbay/cebridge.py
@@ -113,6 +113,27 @@ def ce_scripts_available() -> bool:
return False
+def viewer_available() -> bool:
+ """True when the installed CE ships its wiki viewer server.
+
+ Switch Bay's Graph tab is CE's own viewer (scripts/viewer_server.py,
+ supervised by ce_viewer_supervisor) served through ``/embed/ce``;
+ without it there is no Graph to show.
+ """
+ try:
+ return (ce_root() / "scripts" / "viewer_server.py").is_file()
+ except OSError:
+ return False
+
+
+def graph_availability(workspace: Path) -> dict[str, bool]:
+ """What the shell needs to decide whether to offer the Graph tab."""
+ return {
+ "installed": viewer_available(),
+ "has_wiki": has_wiki(Path(workspace)),
+ }
+
+
def output_dir(workspace: Path) -> Path:
"""Where viewer.sh writes the bundle for this workspace."""
cache = Path.home() / ".cache" / "curiosity-engine" / "wiki-view"
diff --git a/src/switchbay/daemon.py b/src/switchbay/daemon.py
index 63a7dc8..4f1a89b 100644
--- a/src/switchbay/daemon.py
+++ b/src/switchbay/daemon.py
@@ -1044,6 +1044,9 @@ async def handle_settings_get(request: web.Request) -> web.Response:
"orchestration_denied_models": orchestration_policy.get_denied_models(workspace),
**_desk_live_view(workspace),
"proxied_skill_embeds": app_settings.get_proxied_skill_embeds(),
+ # The Graph tab is CE's viewer through /embed/ce. Without a CE
+ # install the shell hides it; Files, RAG, Agents keep working.
+ "ce_graph": cebridge.graph_availability(workspace),
})
diff --git a/tests/unit/test_ce_bundle_freshness.py b/tests/unit/test_ce_bundle_freshness.py
new file mode 100644
index 0000000..d449c5d
--- /dev/null
+++ b/tests/unit/test_ce_bundle_freshness.py
@@ -0,0 +1,60 @@
+"""CE wiki-view bundle freshness: a workspace edited while another one was
+active must not come back with a stale Graph after a switch."""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from switchbay import ce_viewer_supervisor as ce
+
+
+def _setup(tmp_path: Path, monkeypatch) -> tuple[Path, Path]:
+ ws = tmp_path / "ws"
+ (ws / "wiki" / "concepts").mkdir(parents=True)
+ page = ws / "wiki" / "concepts" / "a.md"
+ page.write_text("# A\n")
+ out = tmp_path / "cache" / "ws"
+ out.mkdir(parents=True)
+ (out / "index.html").write_text("")
+ (out / "data.json").write_text("{}")
+ monkeypatch.setattr(ce.cebridge, "output_dir", lambda _w: out)
+ return ws, out
+
+
+def _age(path: Path, seconds: float) -> None:
+ t = path.stat().st_mtime - seconds
+ os.utime(path, (t, t))
+
+
+def _age_wiki(ws: Path, seconds: float) -> None:
+ for p in [ws / "wiki", *(ws / "wiki").rglob("*")]:
+ _age(p, seconds)
+
+
+def test_bundle_newer_than_wiki_is_ready(tmp_path: Path, monkeypatch) -> None:
+ ws, _out = _setup(tmp_path, monkeypatch)
+ _age_wiki(ws, 100)
+ assert ce.bundle_ready(ws) is True
+
+
+def test_page_edited_after_build_is_stale(tmp_path: Path, monkeypatch) -> None:
+ ws, out = _setup(tmp_path, monkeypatch)
+ _age(out / "data.json", 100)
+ assert ce.bundle_ready(ws) is False
+
+
+def test_deleted_page_marks_bundle_stale(tmp_path: Path, monkeypatch) -> None:
+ ws, out = _setup(tmp_path, monkeypatch)
+ _age_wiki(ws, 200)
+ _age(out / "data.json", 100)
+ assert ce.bundle_ready(ws) is True
+ # Deleting a page only bumps its folder's mtime.
+ (ws / "wiki" / "concepts" / "a.md").unlink()
+ assert ce.bundle_ready(ws) is False
+
+
+def test_missing_bundle_is_not_ready(tmp_path: Path, monkeypatch) -> None:
+ ws, out = _setup(tmp_path, monkeypatch)
+ (out / "data.json").unlink()
+ assert ce.bundle_ready(ws) is False
diff --git a/tests/unit/test_ce_graph_availability.py b/tests/unit/test_ce_graph_availability.py
new file mode 100644
index 0000000..073745f
--- /dev/null
+++ b/tests/unit/test_ce_graph_availability.py
@@ -0,0 +1,54 @@
+"""Graph is Curiosity Engine's viewer: the shell offers it only when CE is installed."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+from aiohttp.test_utils import make_mocked_request
+
+from switchbay import cebridge, daemon, media_settings
+
+
+def _ce(tmp_path: Path, *, viewer: bool) -> Path:
+ root = tmp_path / "ce"
+ (root / "scripts").mkdir(parents=True)
+ (root / "scripts" / "planner.py").write_text("", encoding="utf-8")
+ if viewer:
+ (root / "scripts" / "viewer_server.py").write_text("", encoding="utf-8")
+ return root
+
+
+def test_graph_unavailable_without_ce_viewer(tmp_path: Path, monkeypatch):
+ root = _ce(tmp_path, viewer=False)
+ monkeypatch.setattr(cebridge, "ce_root", lambda: root)
+ ws = tmp_path / "ws"
+ (ws / "wiki").mkdir(parents=True)
+ assert cebridge.graph_availability(ws) == {"installed": False, "has_wiki": True}
+
+
+def test_graph_available_with_ce_viewer(tmp_path: Path, monkeypatch):
+ root = _ce(tmp_path, viewer=True)
+ monkeypatch.setattr(cebridge, "ce_root", lambda: root)
+ ws = tmp_path / "ws"
+ ws.mkdir()
+ assert cebridge.graph_availability(ws) == {"installed": True, "has_wiki": False}
+ (ws / "wiki").mkdir()
+ assert cebridge.graph_availability(ws) == {"installed": True, "has_wiki": True}
+
+
+@pytest.mark.asyncio
+async def test_settings_reports_ce_graph_and_keeps_agents_toggle(tmp_path: Path, monkeypatch):
+ root = _ce(tmp_path, viewer=True)
+ monkeypatch.setattr(cebridge, "ce_root", lambda: root)
+
+ monkeypatch.setattr(media_settings, "status_payload", lambda: {})
+ ws = tmp_path / "ws"
+ (ws / "wiki").mkdir(parents=True)
+ req = make_mocked_request("GET", "/api/settings", app={"workspace": ws})
+ resp = await daemon.handle_settings_get(req)
+ body = json.loads(resp.body)
+ assert body["ce_graph"] == {"installed": True, "has_wiki": True}
+ # The okstratr Agents toggle keeps its key and default.
+ assert body["proxied_skill_embeds"] is False
diff --git a/tests/unit/test_procedure_execution_palette.py b/tests/unit/test_procedure_execution_palette.py
index aefdb47..8fc673a 100644
--- a/tests/unit/test_procedure_execution_palette.py
+++ b/tests/unit/test_procedure_execution_palette.py
@@ -1,23 +1,27 @@
-"""Lock procedure/execution Graph palette hexes + sidebar/template coverage."""
+"""Lock procedure/execution Graph palette hexes + label-type coverage.
+
+The PWA Graph is Curiosity Engine's own viewer (via /embed/ce); the
+`widgets/graph` fork remains only for the VS Code graph webview.
+"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
-GRAPH = ROOT / "frontend" / "src" / "widgets" / "graph"
+FRONTEND = ROOT / "frontend" / "src"
+GRAPH = FRONTEND / "widgets" / "graph"
+EMBED = FRONTEND / "widgets" / "embed"
PROC = "#5e4fa2"
EXEC = "#c51b8a"
-def test_css_vars_and_sidebar_dots():
+def test_css_vars_and_dots():
+ palette = (FRONTEND / "palette.css").read_text()
+ assert f"--type-procedure:{PROC}" in palette.replace(" ", "")
+ assert f"--type-execution:{EXEC}" in palette.replace(" ", "")
css = (GRAPH / "ce-graph.css").read_text()
- assert f"--type-procedure: {PROC}" in css or f"--type-procedure: {PROC}" in css or f"--type-procedure:{PROC}" in css.replace(" ", "")
- assert PROC in css
- assert EXEC in css
- assert "--type-procedure" in css
- assert "--type-execution" in css
assert ".dot.dot-procedure" in css
assert ".dot.dot-execution" in css
@@ -34,13 +38,13 @@ def test_atlas_type_keys_include_procedure_execution():
assert '"execution"' in src
-def test_sidebar_canonical_order_and_labels():
- src = (GRAPH / "static" / "sidebar.js").read_text()
- assert "procedure:" in src and "procedures:" in src
- assert "execution:" in src and "executions:" in src
- assert "'procedure'" in src and "'execution'" in src
- assert "Procedures" in src
- assert "Executions" in src
+def test_ce_embed_label_types_include_procedure_execution():
+ # CE's own embed shell lacks these two rows; Switchbay's copy adds them.
+ src = (EMBED / "ceEmbedShell.html").read_text()
+ assert 'data-type="procedure"' in src
+ assert 'data-type="execution"' in src
+ assert "dot-procedure" in src
+ assert "dot-execution" in src
def test_graph_canonical_and_label_types():
diff --git a/tests/unit/test_upload_vault.py b/tests/unit/test_upload_vault.py
index b598d6e..faafa0a 100644
--- a/tests/unit/test_upload_vault.py
+++ b/tests/unit/test_upload_vault.py
@@ -1,7 +1,8 @@
-"""POST /api/upload-vault: the graph sidebar's raw upload into vault/raw/.
+"""POST /api/upload-vault: raw uploads into vault/raw/.
-Mirrors CE's viewer_server.py route of the same name, which edit.js was
-forked against.
+Used by the Files toolbar uploader and by the CE Graph sidebar `+`
+(both open UploadVaultDialog). Mirrors CE's viewer_server.py route of the
+same name, which CE's own edit.js posts to.
"""
from __future__ import annotations
@@ -17,7 +18,7 @@
from switchbay import daemon
ROOT = Path(__file__).resolve().parents[2]
-EDIT_JS = ROOT / "frontend" / "src" / "widgets" / "graph" / "static" / "edit.js"
+UPLOAD_TS = ROOT / "frontend" / "src" / "lib" / "uploadVault.ts"
def _app(ws: Path) -> web.Application:
@@ -28,7 +29,7 @@ def _app(ws: Path) -> web.Application:
def _form(*files: tuple[str, bytes]) -> FormData:
- # Same shape edit.js builds: one `file` field per chosen file. Browsers
+ # Same shape uploadVault.ts (and CE's edit.js) builds: one `file` field per chosen file. Browsers
# send the filename unescaped, so don't let aiohttp percent-encode it.
form = FormData(quote_fields=False)
for name, data in files:
@@ -37,10 +38,10 @@ def _form(*files: tuple[str, bytes]) -> FormData:
return form
-def test_edit_js_posts_to_a_registered_daemon_route(tmp_path: Path) -> None:
- src = EDIT_JS.read_text(encoding="utf-8")
- m = re.search(r"fetch\('(/api/upload-vault)',\s*\{\s*method:\s*'POST'", src)
- assert m, "edit.js no longer posts the upload to /api/upload-vault"
+def test_uploader_posts_to_a_registered_daemon_route(tmp_path: Path) -> None:
+ src = UPLOAD_TS.read_text(encoding="utf-8")
+ m = re.search(r'UPLOAD_VAULT_URL\s*=\s*"(/api/upload-vault)"', src)
+ assert m, "uploadVault.ts no longer posts the upload to /api/upload-vault"
app = daemon.build_app(tmp_path)
req = make_mocked_request("POST", m.group(1), app=app)
diff --git a/uv.lock b/uv.lock
index 7728f38..3efbd06 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2515,7 +2515,7 @@ wheels = [
[[package]]
name = "switchbay"
-version = "0.13.2"
+version = "0.14.0"
source = { virtual = "." }
dependencies = [
{ name = "aiohttp" },