diff --git a/apps/web/src/components/sidebar/org-rail.tsx b/apps/web/src/components/sidebar/org-rail.tsx index 7d4634b699..472b69ac2b 100644 --- a/apps/web/src/components/sidebar/org-rail.tsx +++ b/apps/web/src/components/sidebar/org-rail.tsx @@ -193,6 +193,44 @@ function RailAppButton({ ); } +/** Where an app opens outside any org and project — only the Site Editor, + * over the `deco serve` on this machine. */ +const LOCAL_APP_ROUTES: Partial> = { + "site-editor": "/site-editor", +}; + +/** The app open outside any org (`/site-editor`): marked as the screen you + * are on, like a recent app, but never recorded — there is no org to keep + * it in. */ +function RailLocalAppButton({ app: appId }: { app: string }) { + const t = useT(); + const app = PROJECT_APPS[appId as keyof typeof PROJECT_APPS]; + const to = LOCAL_APP_ROUTES[appId]; + if (!app || !to) return null; + + return ( + + + + + + + + {t(app.labelKey)} + + + ); +} + /** Records the app the route is on, by URL as much as by launcher. Mounted * by `Layout` beside the rail but on every viewport, since the org home's * app order reads this history on mobile too. */ @@ -218,6 +256,9 @@ export function OrgRail() { /** Which recent is the screen you are on, so the rail marks it the same way * it marks the current org. */ const openApp = useOpenApp(); + /** Outside any org there are no recents, only the app on screen. */ + const localApp = openApp && openApp.projectId === null ? openApp.app : null; + const recents = localApp ? [] : recent; const orgs = (organizations ?? []) as RailOrg[]; const { recent: recentOrgs, remember } = useRecentOrgs(); @@ -285,7 +326,7 @@ export function OrgRail() { - {recent.length > 0 && ( + {(recents.length > 0 || localApp) && ( <> {/* A rule and not a gap: below it the marks stop meaning "an org you belong to" and start meaning "a thing you had open", and @@ -294,7 +335,7 @@ export function OrgRail() { className="my-2 h-px w-6 shrink-0 bg-sidebar-border" aria-hidden /> - {recent.map((entry) => ( + {recents.map((entry) => ( forget(entry)} /> ))} + {localApp && } )} diff --git a/apps/web/src/hooks/use-recent-apps.ts b/apps/web/src/hooks/use-recent-apps.ts index a200f3a2f6..48201eba3b 100644 --- a/apps/web/src/hooks/use-recent-apps.ts +++ b/apps/web/src/hooks/use-recent-apps.ts @@ -16,6 +16,8 @@ import { formatPinnedViewTabId } from "@/layouts/main-panel-tabs/tab-id"; import { appOpenKey, dropRecentApp, + openAppOf, + type OpenApp, pushAppOpen, pushRecentApp, type RecentApp, @@ -66,35 +68,16 @@ export function useRecentApps(orgSlug: string): { }; } -interface OpenApp { - app: string; - projectId: string; - connection?: { id: string; toolName: string }; -} - /** The app the current route IS, a connection's app included, or null for a - * route with no launchable app. */ + * route with no launchable app. See `openAppOf`. */ export function useOpenApp(): OpenApp | null { return useRouterState({ - select: (state): OpenApp | null => { - const match = state.matches.findLast((it) => it.staticData.mainView); - const view = match?.staticData.mainView; - const params = match?.params as - | { agentId?: string; connectionId?: string; toolName?: string } - | undefined; - const projectId = params?.agentId; - if (!view || !projectId) return null; - if (view === "app" && params.connectionId && params.toolName) { - return { - app: formatPinnedViewTabId(params.connectionId, params.toolName), - projectId, - connection: { id: params.connectionId, toolName: params.toolName }, - }; - } - /** Content and Code are tabs of the Site Editor, not apps of their own. */ - const app = match.staticData.siteEditorView ? "site-editor" : view; - return app in PROJECT_APPS ? { app, projectId } : null; - }, + select: (state) => + openAppOf( + state.matches, + (app) => app in PROJECT_APPS, + formatPinnedViewTabId, + ), /** Stable across unrelated route state, so the effect fires once per app * rather than once per navigation. */ structuralSharing: true, @@ -108,9 +91,12 @@ export function useRememberOpenApp(orgSlug: string): void { const open = useOpenApp(); const opens = useAppOpens(orgSlug); /** By id, not the project scope: the scope drops projects its picker does - * not offer, and an app opened in one of those never reached the rail. */ + * not offer, and an app opened in one of those never reached the rail. + * No project, no entry: the account-less `/site-editor` is never + * recorded. */ const project = useVirtualMCPNonBlocking(open?.projectId); - const title = open && project?.id === open.projectId ? project.title : null; + const title = + open?.projectId && project?.id === open.projectId ? project.title : null; const pinned = open?.connection && project ? pinnedViewsOf(project).find( @@ -128,8 +114,9 @@ export function useRememberOpenApp(orgSlug: string): void { // oxlint-disable-next-line ban-use-effect/ban-use-effect -- the event is the navigation itself; a deep link has no click to record on useEffect(() => { - if (open) opens.remember(appOpenKey(open.projectId, open.app)); - if (!open || !title) return; + if (!open?.projectId) return; + opens.remember(appOpenKey(open.projectId, open.app)); + if (!title) return; remember({ app: open.app, projectId: open.projectId, diff --git a/apps/web/src/lib/recent-apps.test.ts b/apps/web/src/lib/recent-apps.test.ts index f1e9daf958..e9f3b8c03f 100644 --- a/apps/web/src/lib/recent-apps.test.ts +++ b/apps/web/src/lib/recent-apps.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { appOpenKey, dropRecentApp, + openAppOf, pushAppOpen, pushRecentApp, type RecentApp, @@ -97,3 +98,113 @@ describe("pushAppOpen", () => { expect(pushAppOpen(["a", "b", "c"], "d", 3)).toEqual(["d", "a", "b"]); }); }); + +describe("openAppOf", () => { + const isApp = (app: string) => + ["site-editor", "assets", "reports"].includes(app); + const connectionApp = (id: string, toolName: string) => + `app:${id}:${toolName}`; + const match = ( + staticData: { + mainView?: string; + siteEditorView?: "preview" | "content" | "code"; + local?: boolean; + }, + params?: Record, + ) => ({ staticData, params }); + + test("a project's app route is that app in that project", () => { + expect( + openAppOf( + [match({}), match({ mainView: "assets" }, { agentId: "p1" })], + isApp, + connectionApp, + ), + ).toEqual({ app: "assets", projectId: "p1" }); + }); + + test("a project's Site Editor tabs are all the Site Editor", () => { + for (const [mainView, siteEditorView] of [ + ["site-editor", "preview"], + ["content", "content"], + ["code", "code"], + ] as const) { + expect( + openAppOf( + [match({ mainView, siteEditorView }, { agentId: "p1" })], + isApp, + connectionApp, + ), + ).toEqual({ app: "site-editor", projectId: "p1" }); + } + }); + + test("a connection's app is that pinned view in that project", () => { + expect( + openAppOf( + [ + match( + { mainView: "app" }, + { agentId: "p1", connectionId: "conn_1", toolName: "open" }, + ), + ], + isApp, + connectionApp, + ), + ).toEqual({ + app: "app:conn_1:open", + projectId: "p1", + connection: { id: "conn_1", toolName: "open" }, + }); + }); + + test("every account-less /site-editor tab is the Site Editor", () => { + for (const [mainView, siteEditorView] of [ + ["site-editor", "preview"], + ["content", "content"], + ["code", "code"], + ] as const) { + expect( + openAppOf( + [match({ mainView, siteEditorView, local: true })], + isApp, + connectionApp, + ), + ).toEqual({ app: "site-editor", projectId: null }); + } + }); + + test("the account-less /site-editor is the Site Editor, with no project", () => { + expect( + openAppOf( + [ + match({}), + match({ + mainView: "content", + siteEditorView: "content", + local: true, + }), + ], + isApp, + connectionApp, + ), + ).toEqual({ app: "site-editor", projectId: null }); + }); + + test("an org-level route with no project is no open app", () => { + expect( + openAppOf([match({ mainView: "reports" }, {})], isApp, connectionApp), + ).toBeNull(); + }); + + test("a place, not an app, is no open app", () => { + expect( + openAppOf( + [match({ mainView: "board" }, { agentId: "p1" })], + isApp, + connectionApp, + ), + ).toBeNull(); + expect(openAppOf([match({})], isApp, connectionApp)).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/recent-apps.ts b/apps/web/src/lib/recent-apps.ts index 9d050f1a72..1ab5af04a4 100644 --- a/apps/web/src/lib/recent-apps.ts +++ b/apps/web/src/lib/recent-apps.ts @@ -65,3 +65,59 @@ export function pushAppOpen( ): string[] { return [key, ...list.filter((it) => it !== key)].slice(0, limit); } + +/** The app a route IS. `projectId` is null for the account-less + * `/site-editor` (`staticData.local`): the app is open, but no project (and + * no org) owns it, so it is marked on the rail and never recorded. + * `connection` is set for a connection's app (a pinned view). */ +export interface OpenApp { + app: string; + projectId: string | null; + connection?: { id: string; toolName: string }; +} + +interface RouteMatchLike { + staticData: { + mainView?: string; + siteEditorView?: string; + local?: boolean; + }; + params?: unknown; +} + +/** Which app the matched routes are, a connection's app included, or null. + * The Site Editor's Preview, Content and Code tabs (`siteEditorView`) are + * all the Site Editor, in a project and in the account-less `/site-editor`. + * `isApp` is the launcher's catalogue and `connectionApp` the pinned view's + * tab id, passed in so this module does not depend on either. Pure and + * tested. */ +export function openAppOf( + matches: readonly RouteMatchLike[], + isApp: (app: string) => boolean, + connectionApp: (connectionId: string, toolName: string) => string, +): OpenApp | null { + const match = matches.findLast((it) => it.staticData.mainView); + if (!match) return null; + const { mainView, siteEditorView, local } = match.staticData; + if (!mainView) return null; + const params = match.params as + | { agentId?: string; connectionId?: string; toolName?: string } + | undefined; + const projectId = params?.agentId; + if ( + projectId && + mainView === "app" && + params.connectionId && + params.toolName + ) { + return { + app: connectionApp(params.connectionId, params.toolName), + projectId, + connection: { id: params.connectionId, toolName: params.toolName }, + }; + } + const app = siteEditorView ? "site-editor" : mainView; + if (!isApp(app)) return null; + if (projectId) return { app, projectId }; + return local ? { app, projectId: null } : null; +}