From 748fe86505bd9a1d91bb9c88776694054d269ed4 Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sat, 20 Jun 2026 19:16:30 +0200 Subject: [PATCH] feat(shell): push project switches to embedded apps over the live bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a project in the shell switcher must reach an embedded project-scoped app (Projects) without tearing down its iframe. - bridge.ts: add setProject() — updates the live bridge identity and re-sends welcome at the NEGOTIATED protocol version (mirrors setTheme). Recreating the bridge instead would send a proactive welcome at the un-negotiated host version, which a v1 app-broker silently drops. - IframeHost.tsx: stop tearing down the bridge on project change (removed `project` from the creation deps); push it live via a setProject effect. - context.tsx: register Projects as a first-party iframe built-in app. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/shell/IframeHost.tsx | 13 ++++++++++++- src/lib/shell/bridge.ts | 11 +++++++++++ src/lib/shell/context.tsx | 11 +++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/components/shell/IframeHost.tsx b/src/components/shell/IframeHost.tsx index eb61859..3701195 100644 --- a/src/components/shell/IframeHost.tsx +++ b/src/components/shell/IframeHost.tsx @@ -111,7 +111,12 @@ export function IframeHost({ bridge.dispose(); bridgeRef.current = null; }; - }, [app, app.url, app.trust, webId, workspacePod, scope, allowWrite, project, podFetch, onResize, onOpen]); + // `project` is intentionally NOT a dep: a project switch must NOT tear down + // and recreate the bridge (its proactive welcome would go out at the + // un-negotiated host version and a v1 app drops it). It's pushed to the live + // bridge via setProject below, exactly like theme. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [app, app.url, app.trust, webId, workspacePod, scope, allowWrite, podFetch, onResize, onOpen]); // Push shell theme changes to the live bridge so the embedded app's chrome // tracks the shell without re-mounting the iframe. @@ -119,6 +124,12 @@ export function IframeHost({ bridgeRef.current?.setTheme(theme); }, [theme]); + // Push project switches to the live bridge so an embedded project-scoped app + // (Projects) follows the shell's switcher — again without re-mounting the frame. + useEffect(() => { + bridgeRef.current?.setProject(project ? { id: project.id, name: project.name } : null); + }, [project]); + if (!app.url) { return (
diff --git a/src/lib/shell/bridge.ts b/src/lib/shell/bridge.ts index 5d66d4c..1216717 100644 --- a/src/lib/shell/bridge.ts +++ b/src/lib/shell/bridge.ts @@ -83,6 +83,12 @@ export interface BridgeOptions { export interface Bridge { /** Push a new color mode to the app (re-broadcasts welcome). Idempotent. */ setTheme(theme: BridgeTheme): void; + /** Push a project switch to the app (re-broadcasts welcome). Idempotent. + * Mirrors {@link setTheme}: the frame stays mounted and the negotiated + * protocol version is preserved, so the welcome actually reaches the app — + * unlike tearing down and recreating the bridge, whose proactive welcome + * goes out at the un-negotiated host version and a v1 app drops it. */ + setProject(project: BridgeIdentity["project"]): void; dispose(): void; } @@ -392,6 +398,11 @@ export function createBridge(opts: BridgeOptions): Bridge { currentTheme = theme; welcome(); }, + setProject(project: BridgeIdentity["project"]) { + if ((currentIdentity.project?.id ?? null) === (project?.id ?? null)) return; + currentIdentity = { ...currentIdentity, project }; + welcome(); + }, dispose() { window.removeEventListener("message", onMessage); unsubscribe(); diff --git a/src/lib/shell/context.tsx b/src/lib/shell/context.tsx index 7d5f656..a4eefa1 100644 --- a/src/lib/shell/context.tsx +++ b/src/lib/shell/context.tsx @@ -95,6 +95,8 @@ const NOTES_URL = siblingUrl(process.env.NEXT_PUBLIC_APP_NOTES_URL, 3120, "/note const CONTACTS_URL = siblingUrl(process.env.NEXT_PUBLIC_APP_CONTACTS_URL, 3130, "/contacts"); const CALENDAR_URL = siblingUrl(process.env.NEXT_PUBLIC_APP_CALENDAR_URL, 3140, "/calendar"); const PHOTOS_URL = siblingUrl(process.env.NEXT_PUBLIC_APP_PHOTOS_URL, 3150, "/photos"); +// Projects serves its app surface at the origin root (no sub-route). +const PROJECTS_URL = siblingUrl(process.env.NEXT_PUBLIC_APP_PROJECTS_URL, 3160, ""); // Slides (mind-slides-v0) is embedded at its `/studio` route. It brokers like the // rest of the suite (its `src/lib/solid/broker.ts` does the v1 handshake), so // embedded it uses the shell's SSO + pod — decks save to `{shellPod}mind-slides/ @@ -245,6 +247,15 @@ function builtinApps(): HostedApp[] { trust: "first-party", widgets: [RECENT_WIDGET], }, + { + key: "projects", + label: "Projects", + icon: "📋", + enabled: true, + url: PROJECTS_URL, + embed: "iframe", + trust: "first-party", + }, { key: "notes", label: "Notes",