Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions apps/web/src/components/sidebar/org-rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
"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 (
<RailItem active label={t(app.labelKey)}>
<Tooltip>
<TooltipTrigger asChild>
<Link
to={to}
aria-label={t(app.labelKey)}
aria-current="page"
data-testid="org-rail-local-app"
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-xl focus-ring opacity-100",
app.tone,
)}
>
<app.Icon size={18} />
</Link>
</TooltipTrigger>
<TooltipContent side="right">{t(app.labelKey)}</TooltipContent>
</Tooltip>
</RailItem>
);
}

/** 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. */
Expand All @@ -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();
Expand Down Expand Up @@ -285,7 +326,7 @@ export function OrgRail() {
</TooltipContent>
</Tooltip>
</RailItem>
{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
Expand All @@ -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) => (
<RailAppButton
key={`${entry.app}:${entry.projectId}`}
entry={entry}
Expand All @@ -306,6 +347,7 @@ export function OrgRail() {
onClose={() => forget(entry)}
/>
))}
{localApp && <RailLocalAppButton app={localApp} />}
</>
)}
</div>
Expand Down
47 changes: 17 additions & 30 deletions apps/web/src/hooks/use-recent-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { formatPinnedViewTabId } from "@/layouts/main-panel-tabs/tab-id";
import {
appOpenKey,
dropRecentApp,
openAppOf,
type OpenApp,
pushAppOpen,
pushRecentApp,
type RecentApp,
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions apps/web/src/lib/recent-apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
appOpenKey,
dropRecentApp,
openAppOf,
pushAppOpen,
pushRecentApp,
type RecentApp,
Expand Down Expand Up @@ -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<string, string>,
) => ({ 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();
});
});
56 changes: 56 additions & 0 deletions apps/web/src/lib/recent-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading