diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 91fb1c339..656b3bea7 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -4535,3 +4535,15 @@ tr.insights-row-clickable:hover { width: 0.9rem; height: 0.9rem; } + +.stage-crumb-back { + display: inline-flex; + flex-shrink: 0; + align-items: center; + margin-right: 0.15rem; + color: var(--muted-foreground); +} + +.stage-crumb-back:hover { + color: var(--foreground); +} diff --git a/apps/web/src/shell/page-crumbs.tsx b/apps/web/src/shell/page-crumbs.tsx new file mode 100644 index 000000000..d76955dd6 --- /dev/null +++ b/apps/web/src/shell/page-crumbs.tsx @@ -0,0 +1,110 @@ +// A full-page view opened from a workbench carries `?from=`, +// so the trail back survives reload and share. Without it the page is just +// its own trail. + +import { useQuery } from "@tanstack/react-query"; +import { Fragment, useSyncExternalStore } from "react"; + +import { listWorkbenches } from "@/chat/workbench-tenants"; +import { useBench } from "../bench-context"; +import { ArrowLeft } from "../lib/icons"; +import { Link } from "../navigation"; +import { tenantKeys } from "../query-client"; +import { getPath, subscribeToPath } from "../router-store"; +import { workbenchPath } from "../workbench-path"; + +const FROM_PARAM = "from"; + +/** `path` with the originating workbench appended, for any link that opens a + * full-page view from a workbench. */ +export function benchLink(path: string, benchId: string | null): string { + if (benchId === null) return path; + const url = new URL(path, window.location.origin); + url.searchParams.set(FROM_PARAM, benchId); + return `${url.pathname}${url.search}${url.hash}`; +} + +export type PageCrumb = { readonly label: string; readonly href?: string }; + +function useFromWorkbench(): { readonly id: string; readonly name: string | null } | null { + // The path store re-renders this on navigation; it keeps the pathname + // only, so the query string is read fresh. + useSyncExternalStore(subscribeToPath, getPath); + const id = new URLSearchParams(window.location.search).get(FROM_PARAM); + const { selectedTenantId } = useBench(); + const tenantId = selectedTenantId ?? ""; + const workbenches = useQuery({ + queryKey: tenantKeys.workbenches(tenantId), + enabled: id !== null && selectedTenantId !== null, + queryFn: () => listWorkbenches(tenantId), + }); + if (id === null) return null; + return { id, name: workbenches.data?.find((w) => w.id === id)?.title ?? null }; +} + +/** The page's crumbs, prefixed with the originating workbench when the URL + * names one; `back` is the arrow's target. Until the name resolves the page + * shows its own trail. */ +export function usePageCrumbs(crumbs: readonly PageCrumb[]): { + readonly crumbs: readonly PageCrumb[]; + readonly back: { readonly href: string; readonly name: string } | null; +} { + const from = useFromWorkbench(); + if (from === null || from.name === null) return { crumbs, back: null }; + const href = workbenchPath(from.id); + return { + crumbs: [ + { label: from.name, href }, + ...crumbs.map((crumb) => + crumb.href === undefined ? crumb : { ...crumb, href: benchLink(crumb.href, from.id) }, + ), + ], + back: { href, name: from.name }, + }; +} + +export function PageBack({ href, name }: { readonly href: string; readonly name: string }) { + return ( + +