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",