Skip to content

Commit 08fc8b3

Browse files
feat(web): bench-aware breadcrumbs on full-page views (CL-9545) (#989)
1 parent 9264403 commit 08fc8b3

3 files changed

Lines changed: 126 additions & 45 deletions

File tree

‎apps/web/src/app.css‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4555,3 +4555,15 @@ tr.insights-row-clickable:hover {
45554555
width: 0.9rem;
45564556
height: 0.9rem;
45574557
}
4558+
4559+
.stage-crumb-back {
4560+
display: inline-flex;
4561+
flex-shrink: 0;
4562+
align-items: center;
4563+
margin-right: 0.15rem;
4564+
color: var(--muted-foreground);
4565+
}
4566+
4567+
.stage-crumb-back:hover {
4568+
color: var(--foreground);
4569+
}

‎apps/web/src/shell/page-crumbs.tsx‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// A full-page view opened from a workbench carries `?from=<workbench id>`,
2+
// so the trail back survives reload and share. Without it the page is just
3+
// its own trail.
4+
5+
import { useQuery } from "@tanstack/react-query";
6+
import { Fragment, useSyncExternalStore } from "react";
7+
8+
import { listWorkbenches } from "@/chat/workbench-tenants";
9+
import { useBench } from "../bench-context";
10+
import { ArrowLeft } from "../lib/icons";
11+
import { Link } from "../navigation";
12+
import { tenantKeys } from "../query-client";
13+
import { getPath, subscribeToPath } from "../router-store";
14+
import { workbenchPath } from "../workbench-path";
15+
16+
const FROM_PARAM = "from";
17+
18+
/** `path` with the originating workbench appended, for any link that opens a
19+
* full-page view from a workbench. */
20+
export function benchLink(path: string, benchId: string | null): string {
21+
if (benchId === null) return path;
22+
const url = new URL(path, window.location.origin);
23+
url.searchParams.set(FROM_PARAM, benchId);
24+
return `${url.pathname}${url.search}${url.hash}`;
25+
}
26+
27+
export type PageCrumb = { readonly label: string; readonly href?: string };
28+
29+
function useFromWorkbench(): { readonly id: string; readonly name: string | null } | null {
30+
// The path store re-renders this on navigation; it keeps the pathname
31+
// only, so the query string is read fresh.
32+
useSyncExternalStore(subscribeToPath, getPath);
33+
const id = new URLSearchParams(window.location.search).get(FROM_PARAM);
34+
const { selectedTenantId } = useBench();
35+
const tenantId = selectedTenantId ?? "";
36+
const workbenches = useQuery({
37+
queryKey: tenantKeys.workbenches(tenantId),
38+
enabled: id !== null && selectedTenantId !== null,
39+
queryFn: () => listWorkbenches(tenantId),
40+
});
41+
if (id === null) return null;
42+
return { id, name: workbenches.data?.find((w) => w.id === id)?.title ?? null };
43+
}
44+
45+
/** The page's crumbs, prefixed with the originating workbench when the URL
46+
* names one; `back` is the arrow's target. Until the name resolves the page
47+
* shows its own trail. */
48+
export function usePageCrumbs(crumbs: readonly PageCrumb[]): {
49+
readonly crumbs: readonly PageCrumb[];
50+
readonly back: { readonly href: string; readonly name: string } | null;
51+
} {
52+
const from = useFromWorkbench();
53+
if (from === null || from.name === null) return { crumbs, back: null };
54+
const href = workbenchPath(from.id);
55+
return {
56+
crumbs: [
57+
{ label: from.name, href },
58+
...crumbs.map((crumb) =>
59+
crumb.href === undefined ? crumb : { ...crumb, href: benchLink(crumb.href, from.id) },
60+
),
61+
],
62+
back: { href, name: from.name },
63+
};
64+
}
65+
66+
export function PageBack({ href, name }: { readonly href: string; readonly name: string }) {
67+
return (
68+
<Link to={href} className="stage-crumb-back" aria-label={`Back to ${name}`}>
69+
<ArrowLeft size={16} aria-hidden="true" />
70+
</Link>
71+
);
72+
}
73+
74+
export function PageCrumbs({ crumbs: pageCrumbs }: { readonly crumbs: readonly PageCrumb[] }) {
75+
const { crumbs, back } = usePageCrumbs(pageCrumbs);
76+
const lastIndex = crumbs.length - 1;
77+
const trail = crumbs.map((crumb, index) => (
78+
<Fragment key={`${String(index)}-${crumb.label}`}>
79+
{index > 0 ? (
80+
<span className="stage-crumbs-sep" aria-hidden="true">
81+
/
82+
</span>
83+
) : null}
84+
{index === lastIndex ? (
85+
<span className="stage-crumb-current" aria-current="page">
86+
{crumb.label}
87+
</span>
88+
) : crumb.href === undefined ? (
89+
<span className="stage-crumb-label">{crumb.label}</span>
90+
) : (
91+
<Link to={crumb.href} className="stage-crumb-link">
92+
{crumb.label}
93+
</Link>
94+
)}
95+
</Fragment>
96+
));
97+
98+
// A one-level page has nowhere to go up to — a Breadcrumb landmark around
99+
// a bare page title is noise, so the landmark appears only for a real
100+
// trail.
101+
if (lastIndex === 0) {
102+
return <div className="stage-crumbs">{trail}</div>;
103+
}
104+
return (
105+
<nav className="stage-crumbs" aria-label="Breadcrumb">
106+
{back !== null ? <PageBack {...back} /> : null}
107+
{trail}
108+
</nav>
109+
);
110+
}

‎apps/web/src/shell/stage-top-bar.tsx‎

Lines changed: 4 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,13 @@
55
// `filter` is per-page (DECISIONS.md -> Search), not shell chrome — a page
66
// with nothing to filter gets no magnifier at all.
77

8-
import { Fragment, type ReactNode } from "react";
8+
import type { ReactNode } from "react";
99

10-
import { Link } from "../navigation";
10+
import { PageCrumbs, type PageCrumb } from "./page-crumbs";
1111
import { Chip, type ChipTone } from "./chip";
1212
import { StageSearch, type StageSearchProps } from "./stage-search";
1313

14-
export type StageCrumb = {
15-
readonly label: string;
16-
/** The route this crumb links to. Omitted on the last crumb — the
17-
* current page is the title, never a link. */
18-
readonly href?: string;
19-
};
14+
export type StageCrumb = PageCrumb;
2015

2116
export function StageTopBar({
2217
crumbs,
@@ -42,7 +37,7 @@ export function StageTopBar({
4237
return (
4338
<header className="stage-top-bar" data-testid="stage-top-bar">
4439
<div className="stage-top-bar-title">
45-
<StageCrumbTrail crumbs={crumbs} />
40+
<PageCrumbs crumbs={crumbs} />
4641
</div>
4742
{hasSubtitle ? (
4843
<>
@@ -58,39 +53,3 @@ export function StageTopBar({
5853
</header>
5954
);
6055
}
61-
62-
function StageCrumbTrail({ crumbs }: { readonly crumbs: readonly StageCrumb[] }) {
63-
const lastIndex = crumbs.length - 1;
64-
const trail = crumbs.map((crumb, index) => (
65-
<Fragment key={`${String(index)}-${crumb.label}`}>
66-
{index > 0 ? (
67-
<span className="stage-crumbs-sep" aria-hidden="true">
68-
/
69-
</span>
70-
) : null}
71-
{index === lastIndex ? (
72-
<span className="stage-crumb-current" aria-current="page">
73-
{crumb.label}
74-
</span>
75-
) : crumb.href === undefined ? (
76-
<span className="stage-crumb-label">{crumb.label}</span>
77-
) : (
78-
<Link to={crumb.href} className="stage-crumb-link">
79-
{crumb.label}
80-
</Link>
81-
)}
82-
</Fragment>
83-
));
84-
85-
// A one-level page has nowhere to go up to — a Breadcrumb landmark around
86-
// a bare page title is noise, so the landmark appears only for a real
87-
// trail.
88-
if (lastIndex === 0) {
89-
return <div className="stage-crumbs">{trail}</div>;
90-
}
91-
return (
92-
<nav className="stage-crumbs" aria-label="Breadcrumb">
93-
{trail}
94-
</nav>
95-
);
96-
}

0 commit comments

Comments
 (0)