From e3d8e8a124d97e44b6de5b4dec20b96b4db9bb24 Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Thu, 8 Oct 2026 22:41:05 +0000
Subject: [PATCH 1/3] Graph: use Curiosity Engine's viewer only (0.14.0)
Drop Switchbay's built-in Graph viewer from the PWA. The Graph tab, the
wiki page list and the Zen graph now mount Curiosity Engine's own viewer
through /embed/ce (CEEmbed.create, shared sidebar + canvas session).
- GET /api/settings reports ce_graph {installed, has_wiki}; without CE
the Graph tab is hidden and graph shortcuts fall back to the Editor,
with no wiki a placeholder is shown. Files uploader, RAG, Agents and
Library do not depend on CE.
- proxied_skill_embeds now only picks the Agents surface (okstratr
observer vs built-in dashboard); Graph ignores it. Settings copy
renamed "Agents: okstratr observer". No settings migration needed.
- Host shims on the CE embed: page HTML sanitized before CE's modal
renders it; KaTeX, table -> Sheet/Plot and slideshow in CE's modal;
CE split panel routed to /api/workspaces/split; Procedure/Execution
label-type rows; in-place Atlas/Classic switch (no app reload);
sidebar + keeps opening UploadVaultDialog (/api/upload-vault).
- Keep the {{WORKSPACE}} sidebar-header substitution; factor it into
ceSidebarHeader.ts with node tests and an e2e check that the header
shows the workspace name, never the raw placeholder.
- Remove GraphTab, CurationReplay, WikiPane, the fork's edit.js and
sidebar.js, and their e2e specs. The remaining fork under
widgets/graph is kept only for the VS Code graph webview.
- Docs: architecture says Graph comes from CE; notices updated.
- Bump to 0.14.0.
Signed-off-by: benjsmith
---
CHANGELOG.md | 34 ++
docs/THIRD-PARTY-NOTICES.md | 3 +-
docs/architecture.md | 48 +-
docs/license-risk-report.md | 2 +-
frontend/package.json | 2 +-
frontend/src/App.tsx | 36 +-
frontend/src/center/builtinTabs.tsx | 14 +-
frontend/src/host.ts | 2 +-
frontend/src/index.css | 194 ++-------
frontend/src/layout/SettingsModal.tsx | 15 +-
frontend/src/layout/WorkspaceSwitcher.tsx | 2 +-
frontend/src/palette.css | 268 ++++++++++++
frontend/src/sidebar/Sidebar.tsx | 76 ++--
frontend/src/sidebar/WikiPane.tsx | 70 ---
frontend/src/walkthrough/Walkthrough.tsx | 4 +-
frontend/src/webview-graph.ts | 1 -
frontend/src/widgets/embed/CeAtlasEmbed.tsx | 257 ++++++++++-
frontend/src/widgets/embed/CeGraphSurface.tsx | 37 ++
frontend/src/widgets/embed/CeSidebarSlot.tsx | 5 +-
.../src/widgets/embed/ceEmbedBootstrap.ts | 13 +-
frontend/src/widgets/embed/ceEmbedSession.ts | 17 +-
frontend/src/widgets/embed/ceEmbedShell.html | 12 +-
.../src/widgets/embed/ceHostBridge.test.ts | 57 +++
frontend/src/widgets/embed/ceHostBridge.ts | 336 ++++++++++++++
.../src/widgets/embed/ceSidebarHeader.test.ts | 41 ++
frontend/src/widgets/embed/ceSidebarHeader.ts | 34 ++
.../src/widgets/embed/ceSidebarShell.html | 2 +-
.../widgets/embed/useProxiedSkillEmbeds.ts | 116 +++--
frontend/src/widgets/graph/CurationReplay.tsx | 73 ----
frontend/src/widgets/graph/GraphTab.tsx | 411 ------------------
frontend/src/widgets/graph/ce-graph.css | 268 +-----------
frontend/src/widgets/graph/graphSearch.ts | 4 +-
frontend/src/widgets/graph/init.ts | 32 +-
frontend/src/widgets/graph/load.ts | 18 +-
frontend/src/widgets/graph/static.d.ts | 15 +-
frontend/src/widgets/graph/static/edit.js | 264 -----------
frontend/src/widgets/graph/static/sidebar.js | 261 -----------
frontend/src/widgets/graph/template.ts | 34 +-
frontend/src/zen/ZenBrowserTab.tsx | 20 +-
frontend/src/zen/ZenShell.tsx | 10 +-
frontend/tests/e2e/atlas-search.spec.ts | 179 --------
frontend/tests/e2e/atlas-select.spec.ts | 87 ----
frontend/tests/e2e/graph-ce-embed.spec.ts | 136 ++++++
frontend/tests/e2e/graph-search.spec.ts | 100 -----
frontend/tests/e2e/minimap-zoom.spec.ts | 99 -----
frontend/tests/e2e/mock_backend.py | 78 +++-
pyproject.toml | 2 +-
src/switchbay/__init__.py | 2 +-
src/switchbay/cebridge.py | 21 +
src/switchbay/daemon.py | 3 +
tests/unit/test_ce_graph_availability.py | 54 +++
.../unit/test_procedure_execution_palette.py | 34 +-
tests/unit/test_upload_vault.py | 19 +-
uv.lock | 2 +-
54 files changed, 1673 insertions(+), 2251 deletions(-)
create mode 100644 frontend/src/palette.css
delete mode 100644 frontend/src/sidebar/WikiPane.tsx
create mode 100644 frontend/src/widgets/embed/CeGraphSurface.tsx
create mode 100644 frontend/src/widgets/embed/ceHostBridge.test.ts
create mode 100644 frontend/src/widgets/embed/ceHostBridge.ts
create mode 100644 frontend/src/widgets/embed/ceSidebarHeader.test.ts
create mode 100644 frontend/src/widgets/embed/ceSidebarHeader.ts
delete mode 100644 frontend/src/widgets/graph/CurationReplay.tsx
delete mode 100644 frontend/src/widgets/graph/GraphTab.tsx
delete mode 100644 frontend/src/widgets/graph/static/edit.js
delete mode 100644 frontend/src/widgets/graph/static/sidebar.js
delete mode 100644 frontend/tests/e2e/atlas-search.spec.ts
delete mode 100644 frontend/tests/e2e/atlas-select.spec.ts
create mode 100644 frontend/tests/e2e/graph-ce-embed.spec.ts
delete mode 100644 frontend/tests/e2e/graph-search.spec.ts
delete mode 100644 frontend/tests/e2e/minimap-zoom.spec.ts
create mode 100644 tests/unit/test_ce_graph_availability.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 304164c..4c364aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,40 @@
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.
+
## 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..29d999a 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;
@@ -44,6 +111,8 @@ export default function CeAtlasEmbed() {
if (st.status === "ready") {
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") {
@@ -80,8 +149,33 @@ export default function CeAtlasEmbed() {
};
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 +183,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 +291,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..1dbc571 100644
--- a/frontend/src/widgets/embed/CeSidebarSlot.tsx
+++ b/frontend/src/widgets/embed/CeSidebarSlot.tsx
@@ -36,8 +36,9 @@ export default function CeSidebarSlot() {
}, []);
// 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..b49ee23 100644
--- a/frontend/src/widgets/embed/ceEmbedBootstrap.ts
+++ b/frontend/src/widgets/embed/ceEmbedBootstrap.ts
@@ -14,6 +14,7 @@
* }) => { destroy(): void }
*/
+import { guardCeModal, 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;
@@ -183,6 +184,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 +197,15 @@ 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();
+ }
+}
+
+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 {
diff --git a/frontend/src/widgets/embed/ceEmbedSession.ts b/frontend/src/widgets/embed/ceEmbedSession.ts
index 3247b96..e385f4b 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,10 @@ 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;
- }
- }
- return "workspace";
-}
-
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 {
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..6daadf9
--- /dev/null
+++ b/frontend/src/widgets/embed/ceHostBridge.test.ts
@@ -0,0 +1,57 @@
+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, 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: [] });
+});
diff --git a/frontend/src/widgets/embed/ceHostBridge.ts b/frontend/src/widgets/embed/ceHostBridge.ts
new file mode 100644
index 0000000..c328aea
--- /dev/null
+++ b/frontend/src/widgets/embed/ceHostBridge.ts
@@ -0,0 +1,336 @@
+/**
+ * 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;
+}
+
+// ── 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/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_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" },
From dd1058591cdc3fcbb540296beb65cbe1fdefa98a Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Fri, 9 Oct 2026 01:54:01 +0200
Subject: [PATCH 2/3] CE viewer: rebuild a stale bundle on start and notice
deleted pages
A workspace edited while another was active came back with its old
Graph, because the cached bundle was served as is on retarget. Treat
the bundle as ready only when it is at least as new as wiki/, and count
directory mtimes so deleting or renaming a page triggers a rebuild.
Signed-off-by: benjsmith
---
src/switchbay/ce_viewer_supervisor.py | 32 ++++++++++----
tests/unit/test_ce_bundle_freshness.py | 60 ++++++++++++++++++++++++++
2 files changed, 84 insertions(+), 8 deletions(-)
create mode 100644 tests/unit/test_ce_bundle_freshness.py
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/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
From d00e228d38c70696840e24df93f7d8591e30d5ae Mon Sep 17 00:00:00 2001
From: benjsmith
Date: Fri, 9 Oct 2026 01:54:01 +0200
Subject: [PATCH 3/3] Graph: refresh on wiki changes, wait for CE restarts,
quiet Zen focus
- On files_changed, wait for CE's data refresh before remounting the
canvas, re-render the page list and update the counts (the canvas used
to remount on the old data). The page list refreshes while Graph is
closed too.
- Wait for the CE viewer to report healthy before loading the embed and
retry a load that raced a restart; failed script tags are dropped so
the retry loads them again, and the fetch shim is released.
- Make CE's Sidebar.setActive/setSearchHits safe when the page list
isn't mounted (Zen), so the node focus after #page= still runs.
Signed-off-by: benjsmith
---
CHANGELOG.md | 6 ++
frontend/src/widgets/embed/CeAtlasEmbed.tsx | 15 +--
frontend/src/widgets/embed/CeSidebarSlot.tsx | 15 +++
.../src/widgets/embed/ceEmbedBootstrap.ts | 24 ++++-
frontend/src/widgets/embed/ceEmbedSession.ts | 98 +++++++++++++++++--
.../src/widgets/embed/ceHostBridge.test.ts | 17 +++-
frontend/src/widgets/embed/ceHostBridge.ts | 29 ++++++
7 files changed, 189 insertions(+), 15 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c364aa..ff98961 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,12 @@ Works with curiosity-engine 1.9.2, curiosity-merge 0.8.4, and okstratr 0.2.0.
- **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
diff --git a/frontend/src/widgets/embed/CeAtlasEmbed.tsx b/frontend/src/widgets/embed/CeAtlasEmbed.tsx
index 29d999a..7d80bbf 100644
--- a/frontend/src/widgets/embed/CeAtlasEmbed.tsx
+++ b/frontend/src/widgets/embed/CeAtlasEmbed.tsx
@@ -109,7 +109,8 @@ export default function CeAtlasEmbed({ showAddFile, suppressDocModal }: Props =
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 });
@@ -140,11 +141,13 @@ export default function CeAtlasEmbed({ showAddFile, suppressDocModal }: Props =
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);
diff --git a/frontend/src/widgets/embed/CeSidebarSlot.tsx b/frontend/src/widgets/embed/CeSidebarSlot.tsx
index 1dbc571..ac880d9 100644
--- a/frontend/src/widgets/embed/CeSidebarSlot.tsx
+++ b/frontend/src/widgets/embed/CeSidebarSlot.tsx
@@ -35,6 +35,21 @@ 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 Switch Bay's upload dialog
// instead (vault/raw/ via /api/upload-vault, optional ingest), the same
diff --git a/frontend/src/widgets/embed/ceEmbedBootstrap.ts b/frontend/src/widgets/embed/ceEmbedBootstrap.ts
index b49ee23..0b88596 100644
--- a/frontend/src/widgets/embed/ceEmbedBootstrap.ts
+++ b/frontend/src/widgets/embed/ceEmbedBootstrap.ts
@@ -14,7 +14,7 @@
* }) => { destroy(): void }
*/
-import { guardCeModal, routeCeSplit } from "./ceHostBridge.ts";
+import { guardCeModal, guardCeSidebar, routeCeSplit } from "./ceHostBridge.ts";
import { isSwitchbayReservedApi } from "./embedMount.ts";
export type CeEmbedOptions = {
embed: true;
@@ -129,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);
});
}
@@ -202,6 +206,7 @@ async function loadCeModules(publicBase: string): Promise {
} finally {
// CE renders page HTML with innerHTML on the daemon origin.
guardCeModal();
+ guardCeSidebar();
}
}
@@ -416,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 e385f4b..efb6b36 100644
--- a/frontend/src/widgets/embed/ceEmbedSession.ts
+++ b/frontend/src/widgets/embed/ceEmbedSession.ts
@@ -75,6 +75,31 @@ function win(): CeWin {
}
+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);
+ }
+}
+
+const BOOT_ATTEMPTS = 3;
+
function injectSidebarHtml(el: HTMLElement, html: string, data: unknown): void {
el.innerHTML = renderSidebarShell(html, data);
const nameEl = el.querySelector(".workspace-name");
@@ -125,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 {
@@ -169,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/ceHostBridge.test.ts b/frontend/src/widgets/embed/ceHostBridge.test.ts
index 6daadf9..fbfe258 100644
--- a/frontend/src/widgets/embed/ceHostBridge.test.ts
+++ b/frontend/src/widgets/embed/ceHostBridge.test.ts
@@ -5,7 +5,7 @@ import { test } from "node:test";
(globalThis as unknown as { window: unknown }).window = globalThis;
const {
- guardCeModal, isCeSplitUrl, toSwitchbaySplitBody,
+ guardCeModal, guardCeSidebar, isCeSplitUrl, toSwitchbaySplitBody,
} = await import("./ceHostBridge.ts");
test("CE modal bodies are sanitized on open (init, refresh and re-open)", () => {
@@ -55,3 +55,18 @@ test("CE split body maps onto Switch Bay's workspace split", () => {
);
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
index c328aea..df3b87a 100644
--- a/frontend/src/widgets/embed/ceHostBridge.ts
+++ b/frontend/src/widgets/embed/ceHostBridge.ts
@@ -75,6 +75,35 @@ export function guardCeModal(clean: (html: string) => string = sanitizeHtml): bo
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). */