fix(ui): show parent tasks in responsive breadcrumbs (#20366)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Tasks can have a parent task.
> - The task header does not show that parent in its navigation path.
> - On a phone, the header removes the task path and shows only the
current title.
> - Long parent and current titles must share the available space.
> - This pull request adds a parent link and limits each displayed task
title to 48 characters.
> - People can move to the parent and keep the current task number in
view.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The navigation path in a task header.

**Current behavior**

The global path omits the parent. The phone header shows only the
current task. Long parent titles cannot share the row safely.

**Proposed behavior**

Show the immediate parent before the current task. Limit each displayed
title to 48 Unicode code points, with an ellipsis. Limit the parent to
40% of the row. On phones, remove the collection link and parent number
so both titles and the current task number fit.

**Reason and benefit**

People can see the task context and open its parent. Full titles stay in
hover text and accessible names. Private parents have no link.

Related: #9302 adds project navigation. This change adds task parent
navigation.

## What Changed

- Add the immediate parent from the existing authorized ancestor
response to the global task path.
- Make parent and current titles shrink. Apply a 48-character display
limit without changing stored titles.
- Keep the current task number visible on phones. Keep private parent
labels noninteractive.
- Add ten Storybook scenarios with the production header. Cover linked
parents, long titles, phone widths, tablet width, dark theme, standalone
tasks, Mine source, and private parents on desktop and phones.
- Add regression tests and document the layout rules in DESIGN.md.
- Keep the source collection and filters when people follow the parent
link. Cover route state, stored state and legacy query links.
- Use the existing noninteractive lock chip for private parents. Hide
its parent number on phones. Refresh its control key when an open task
changes screen size. Both regressions failed before their fixes and pass
after them.
- Refresh the SDK public source registry after the master update. The
existing helper keeps all runtime and dependency pins unchanged. This
repairs three support tests that failed on stale master source
identities.

## Verification

- Focused UI tests: `pnpm exec vitest run
ui/src/components/BreadcrumbBar.test.tsx
ui/src/pages/IssueDetail.test.tsx` — 280 passed.
- UI typecheck: `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm build-storybook` — passed.
- `pnpm check:token-gates` — passed.
- Catalog checks and 30 map/catalog tests — passed.
- Chromium: all ten scenarios rendered with no page errors. Story play
functions finished. Checked 1440, 768, 390 and 320 pixel widths,
keyboard parent navigation, private parent links, title limits and
horizontal overflow.
- Private-parent browser check: resize an open task from 1440 to 320
pixels and back. The chip changes from the parent number to Private and
back. It remains noninteractive.
- Screenshots were captured in light and dark themes. They are task
evidence and are not committed.
- This verifies the header with fixture data and the page wiring with
mocked APIs. It does not claim live backend authorization or a pixel
baseline comparison. The static server returns 404 for optional shell
context reads. After keyboard navigation, destination task reads also
return 404 because the preview has no task backend. The header renders
and all play checks pass.
- Case discovery and OpenAPI tests: 47 passed.
- Current head: `e7a1ada02e75e9b2cbd5f6076ad6c113954f3a4b`. [CI run
42834603680](https://github.com/paperclipai/paperclip/actions/runs/42834603680)
passed. All 61 head check runs are complete: 58 passed and three
conditional jobs were skipped. The legacy Snyk status passed.
[Greptile](https://github.com/paperclipai/paperclip/pull/20366#issuecomment-6751938924)
reviewed this head at 5/5 with no actionable findings. All four inline
review threads are resolved.
- After the master update: all 280 UI tests, UI typecheck, token gates,
Storybook build, 30 catalog tests and ten Chromium stories pass. All six
screenshots match the saved images byte for byte.
- SDK and process support checks: 105 tests pass. The source pin helper
has eight passing tests. The three SDK failures reproduced before the
registry refresh and passed after it. The helper check reports no
remaining source drift and no runtime pin changes.
- The earlier case discovery failure reproduced before the summary
correction and passed after it. All server shards passed on the next
head. That CI run failed one unchanged Rust process-reaping test: its
owned tool stopped, but two unattributed orphan processes blocked
verification. The earlier CI run passed the same Rust inputs. Cross-test
process interference is possible; the historical log does not prove the
cause. The next CI run passed Rust. That run failed three SDK checks on
stale master source pins and one process inspection check. The source
pin repair is in this head. The process check passes locally, but the
earlier process inspection failure remains unexplained.
- The same prior run failed the cohort migration resume test. Its upload
barrier releases after five seconds; maximum uploads were seven and
three in the two attempts, below eight. Three server files also exceeded
the 180-second file budget. These test inputs come from master. A local
attempt could not reproduce that CI assertion because its child Git
command failed in this execution environment. Fresh CI 42834603680
passed these paths, including every server shard and Runner support. No
test or process safety check was weakened.

## Risks

- Breadcrumb titles display less text. Hover text and accessible names
retain the full title.
- The global path shows the immediate parent. People can follow parent
links through a deeper task tree.
- The shared breadcrumb component also serves other pages. Existing
header and agent-switcher tests pass.
- No schema, API behavior or stored title change.
- SDK source identities must match the integrated source. The registry
change uses the existing helper and preserves all runtime and dependency
pins.

## Model Used

- OpenAI Codex, based on GPT-6. The exact runtime model ID and context
window size are not exposed. Used reasoning, repository tools and
Chromium verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-10 08:54:39 -06:00
1 parent 8d2bf78323
commit cac82ed4d9
7 files changed
+330 -26

No files matched your search

+7
View File
@@ -841,6 +841,13 @@ status picker with its monospaced task ID, project and people. The title remains
editable and the existing task-actions menu stays at the upper right. Typography
and spacing use the shared tokens; toggling off restores the ordinary header.
Task breadcrumbs link the immediate parent before the current task. Each task
title displays at most 48 Unicode code points, including an ellipsis; saved
titles, hover text and accessible names retain the full title. The parent uses
at most 40% of the breadcrumb row, and both titles truncate when space runs out.
Mobile omits the collection link and parent identifier to preserve both titles
and the current task identifier. Private parents show a noninteractive label.
The local task detail also offers a **Thread preview** selector. DES-1 opens a
complete synthetic conversation matching the staging transcript layout; other
tasks start with their saved thread. Synthetic runner events pass through
+46 -6
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, useEffect, type ReactNode } from "react";
import { act, useEffect, type ComponentProps, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BreadcrumbProvider, useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -24,8 +24,8 @@ vi.mock("@/hooks/useAgentChatNavigation", () => ({
vi.mock("@/lib/router", () => ({
useNavigate: () => navigate,
Link: ({ children, className, to }: { children: ReactNode; className?: string; to: string }) => (
<a className={className} href={to}>{children}</a>
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => (
<a {...props} href={to}>{children}</a>
),
}));
@@ -62,12 +62,16 @@ function TaskBreadcrumbs({
taskDetailLayout = false,
identifier = "PAP-16679",
sourceHref = "/issues",
parentTitle,
title = "Hire your first engineer and create a hiring plan",
}: {
onOpen?: () => void;
panelControl?: { open: boolean; onToggle: () => void };
taskDetailLayout?: boolean;
identifier?: string;
sourceHref?: string;
parentTitle?: string;
title?: string;
}) {
const {
setBreadcrumbs,
@@ -78,8 +82,9 @@ function TaskBreadcrumbs({
useEffect(() => {
setBreadcrumbs([
{ label: "Tasks", href: sourceHref },
...(parentTitle ? [{ label: parentTitle, identifier: "PAP-120", href: "/issues/PAP-120" }] : []),
{
label: "Hire your first engineer and create a hiring plan",
label: title,
identifier,
leading: "status",
},
@@ -104,6 +109,8 @@ function TaskBreadcrumbs({
setBreadcrumbToolbar,
setBreadcrumbs,
sourceHref,
parentTitle,
title,
]);
return <BreadcrumbBar taskDetailLayout={taskDetailLayout} />;
@@ -234,7 +241,8 @@ describe("BreadcrumbBar", () => {
expect(container.querySelector('a[href="/TES/issues"]')).toBeNull();
const identifier = container.querySelector('[data-slot="task-title-identifier"]');
expect(identifier?.textContent).toBe("TES-3");
expect(identifier?.previousElementSibling?.textContent).toBe("Hire your first engineer and create a hiring plan");
expect(identifier?.previousElementSibling?.textContent).toBe("Hire your first engineer and create a hiring pl…");
expect(identifier?.previousElementSibling?.getAttribute("title")).toBe("Hire your first engineer and create a hiring plan");
expect(identifier?.previousElementSibling?.className).toContain("truncate");
expect(container.querySelector('button[aria-label="Open sidebar"]')).not.toBeNull();
});
@@ -259,6 +267,38 @@ describe("BreadcrumbBar", () => {
expect(onOpen).toHaveBeenCalledOnce();
});
it.each([false, true])("keeps the parent linked beside the current task (mobile=%s)", async (mobile) => {
viewport.isMobile = mobile;
await act(async () => root.render(
<BreadcrumbProvider><TaskBreadcrumbs taskDetailLayout parentTitle="Prepare the engineering team" /></BreadcrumbProvider>,
));
const parent = container.querySelector<HTMLAnchorElement>('a[href="/issues/PAP-120"]');
expect(parent?.textContent).toContain("Prepare the engineering team");
expect(parent?.getAttribute("aria-label")).toBe("Prepare the engineering team PAP-120");
expect(container.querySelector('[aria-current="page"]')?.textContent).toContain("PAP-16679");
if (mobile) {
expect(container.querySelector('a[href="/issues"]')).toBeNull();
expect(parent?.textContent).not.toContain("PAP-120");
}
});
it.each([false, true])("caps each task breadcrumb at 48 characters and preserves full names (mobile=%s)", async (mobile) => {
viewport.isMobile = mobile;
const parentTitle = "Plan the engineering team and hiring process for the next product launch";
const title = "🚀".repeat(60);
await act(async () => root.render(
<BreadcrumbProvider><TaskBreadcrumbs taskDetailLayout parentTitle={parentTitle} title={title} /></BreadcrumbProvider>,
));
const parent = container.querySelector<HTMLAnchorElement>('a[href="/issues/PAP-120"]');
const page = container.querySelector('[aria-current="page"]');
expect(parent?.textContent).toContain(`${Array.from(parentTitle).slice(0, 47).join("")}…`);
expect(parent?.title).toBe(parentTitle);
const label = page?.querySelector('[title]');
expect(label?.textContent).toBe(`${"🚀".repeat(47)}…`);
expect(label?.getAttribute("title")).toBe(title);
expect(page?.getAttribute("aria-label")).toContain(title);
});
it("appends the task identifier to the task title in the task-detail header", async () => {
await act(async () => {
root.render(
@@ -276,7 +316,7 @@ describe("BreadcrumbBar", () => {
expect(identifier?.closest(".relative")?.className).toContain("h-(--sz-60px)");
const title = Array.from(container.querySelectorAll("span"))
.find((element) => element.textContent === "Hire your first engineer and create a hiring plan");
.find((element) => element.getAttribute("title") === "Hire your first engineer and create a hiring plan");
const tasksLink = container.querySelector<HTMLAnchorElement>('a[href="/issues"]');
const header = tasksLink?.closest(".relative");
expect(header?.classList).toContain("px-4");
+34 -19
View File
@@ -21,6 +21,14 @@ import { useCoordinatorPresentation } from "./coordinator/CoordinatorPresentatio
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
const MAX_TASK_TITLE_CHARACTERS = 48;
function taskTitleLabel(title: string) {
const characters = Array.from(title);
return characters.length > MAX_TASK_TITLE_CHARACTERS
? `${characters.slice(0, MAX_TASK_TITLE_CHARACTERS - 1).join("")}…`
: title;
}
/** Task identifier rendered in gray monospace beside its breadcrumb label. */
function CrumbIdentifier({ identifier }: { identifier?: string }) {
if (!identifier) return null;
@@ -123,7 +131,9 @@ export function BreadcrumbBar({ taskDetailLayout = false, showPropertiesToggle =
);
const currentCrumb = breadcrumbs[breadcrumbs.length - 1];
if (isMobile && breadcrumbs[0]?.label === "Tasks" && currentCrumb.identifier) {
const isTaskTrail = Boolean(currentCrumb.identifier);
const visibleBreadcrumbs = isMobile && isTaskTrail && breadcrumbs.length > 1 ? breadcrumbs.slice(1) : breadcrumbs;
if (isMobile && isTaskTrail && visibleBreadcrumbs.length === 1) {
return (
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4">
{menuButton}
@@ -131,7 +141,7 @@ export function BreadcrumbBar({ taskDetailLayout = false, showPropertiesToggle =
{currentCrumb.leading ? (
<span className="flex shrink-0 items-center self-center">{currentCrumb.leading}</span>
) : null}
<span className="min-w-0 flex-1 truncate" title={currentCrumb.label}>{currentCrumb.label}</span>
<span className="min-w-0 flex-1 truncate" title={currentCrumb.label} aria-label={currentCrumb.label}>{taskTitleLabel(currentCrumb.label)}</span>
<CrumbIdentifier identifier={currentCrumb.identifier} />
</h1>
{globalToolbarSlots}
@@ -143,52 +153,57 @@ export function BreadcrumbBar({ taskDetailLayout = false, showPropertiesToggle =
<div className="min-w-0 overflow-hidden flex-1">
<Breadcrumb className="min-w-0 overflow-hidden">
<BreadcrumbList className="flex-nowrap">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
const pinnedIdentifier = taskDetailLayout && isLast && Boolean(crumb.identifier);
{visibleBreadcrumbs.map((crumb, i) => {
const isLast = i === visibleBreadcrumbs.length - 1;
const isTask = isTaskTrail && (isMobile || i > 0 || visibleBreadcrumbs.length === 1);
const pinnedIdentifier = (taskDetailLayout || isMobile) && isLast && Boolean(crumb.identifier);
const label = isTask ? taskTitleLabel(crumb.label) : crumb.label;
const accessibleLabel = [crumb.label, crumb.identifier].filter(Boolean).join(" ");
return (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={cn(isLast ? "min-w-0" : "shrink-0", pinnedIdentifier && "flex-1")}>
<BreadcrumbItem className={cn(isLast || isTask ? "min-w-0" : "shrink-0", isTask && !isLast && "max-w-(--breadcrumb-parent-max-width)", pinnedIdentifier && "flex-1")}>
{crumb.control ?? (isLast || !crumb.href ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className={cn("flex min-w-0 gap-1.5", crumb.trailing ? "items-center" : "items-baseline", pinnedIdentifier && "flex-1")}>
<BreadcrumbPage aria-current={isLast ? "page" : undefined} aria-label={accessibleLabel} className={cn("flex min-w-0 gap-1.5", crumb.trailing ? "items-center" : "items-baseline", (isTask || pinnedIdentifier) && "flex-1")}>
{crumb.leading && (
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className={cn("min-w-0 truncate", pinnedIdentifier && "flex-1")}>{crumb.label}</span>
{taskDetailLayout && isLast ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
{!taskDetailLayout && !isMobile ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span title={crumb.label} className={cn("min-w-0 truncate", pinnedIdentifier && "flex-1")}>{label}</span>
{(taskDetailLayout || isMobile) && isLast ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
</BreadcrumbPage>
) : (
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
<BreadcrumbPage aria-current={isLast ? "page" : undefined} aria-label={accessibleLabel} title={crumb.label} className="truncate">{label}</BreadcrumbPage>
)
) : (
<BreadcrumbLink asChild>
{crumb.leading || crumb.identifier ? (
<Link
to={crumb.href} onClick={crumb.onClick}
title={crumb.label} aria-label={accessibleLabel}
className={cn(
"flex min-w-0 items-baseline gap-1.5",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
isTask && "flex-1",
i === 0 && !isTask && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
)}
>
{crumb.leading && (
<span className="flex shrink-0 items-center self-center">{crumb.leading}</span>
)}
{!taskDetailLayout ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{crumb.label}</span>
{taskDetailLayout && isLast ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
{!isMobile ? <CrumbIdentifier identifier={crumb.identifier} /> : null}
<span className="min-w-0 truncate">{label}</span>
</Link>
) : (
<Link
to={crumb.href} onClick={crumb.onClick}
title={crumb.label} aria-label={accessibleLabel}
className={cn(
"min-w-0 truncate",
i === 0 && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
i === 0 && !isTask && "font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground",
)}
>
{crumb.label}
{label}
</Link>
)}
</BreadcrumbLink>
@@ -218,7 +233,7 @@ export function BreadcrumbBar({ taskDetailLayout = false, showPropertiesToggle =
<span className="flex shrink-0 items-center self-center">{breadcrumbs[0].leading}</span>
)}
<CrumbIdentifier identifier={breadcrumbs[0].identifier} />
<span className="truncate">{breadcrumbs[0].label}</span>
<span className="truncate" title={breadcrumbs[0].label}>{isTaskTrail ? taskTitleLabel(breadcrumbs[0].label) : breadcrumbs[0].label}</span>
</h1>
) : (
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
@@ -246,7 +261,7 @@ export function BreadcrumbBar({ taskDetailLayout = false, showPropertiesToggle =
{menuButton}
{breadcrumbTrail}
{globalToolbarSlots}
{propertiesToggle}
{isMobile && isTaskTrail ? null : propertiesToggle}
</div>
);
}
+1
View File
@@ -209,6 +209,7 @@
}
:root {
--breadcrumb-parent-max-width: 40%;
color-scheme: light;
--font-paper-mono: "Paper Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--file-tree-indent-base: calc(var(--spacing) * 2);
+90 -1
View File
@@ -1,6 +1,8 @@
import { TaskChatPausedTakeover, type TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
// @vitest-environment jsdom
import type { Breadcrumb } from "../context/BreadcrumbContext";
import { DispositionRecoveryNotice, type DispositionRecoverySnapshot } from "../components/DispositionRecoveryNotice";
import { RichWorkProductCard } from "../components/task-chat/RichWorkProductCard";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -46,6 +48,8 @@ import { taskPluginPanelActivationKey } from "@/hooks/useTaskPluginPanels";
import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
readIssueDetailBreadcrumb,
rememberIssueDetailLocationState,
} from "../lib/issueDetailBreadcrumb";
import { getRecentTasksStorageKey, readRecentTasks } from "../lib/recent-tasks";
import { getLastProjectId, trackRecentProject } from "../lib/recent-projects";
@@ -2882,7 +2886,7 @@ describe("IssueDetail", () => {
},
);
it("keeps hierarchy breadcrumbs and label chips out of the Streamlined task header", async () => {
it("includes the parent in the global Streamlined breadcrumbs while keeping label chips out of the task header", async () => {
mockIssuesApi.get.mockResolvedValue(
createIssue({
ancestors: [
@@ -2891,6 +2895,7 @@ describe("IssueDetail", () => {
identifier: "PAP-0",
title: "Parent task visible in Properties",
},
{ id: "root-1", identifier: "PAP-99", title: "Root task" },
] as Issue["ancestors"],
labels: [
{
@@ -2916,6 +2921,90 @@ describe("IssueDetail", () => {
);
expect(container.textContent).not.toContain("Quick win");
expect(container.textContent).toContain("Issue detail smoke");
expect(mockSetBreadcrumbs).toHaveBeenLastCalledWith([
{ label: "Tasks", href: "/issues" },
expect.objectContaining({ label: "Parent task visible in Properties", identifier: "PAP-0", href: "/issues/PAP-0" }),
expect.objectContaining({ label: "Issue detail smoke" }),
]);
});
it.each(["location", "stored", "query"] as const)("preserves the %s collection context when opening the breadcrumb parent", async (source) => {
const state = createIssueDetailLocationState("Mine", "/issues?view=mine&status=in_progress", "inbox");
if (source === "location") mockLocation.state = state;
if (source === "stored") rememberIssueDetailLocationState("PAP-1", state);
if (source === "query") mockLocation.search = "?from=inbox&fromHref=%2Fissues%3Fview%3Dmine%26status%3Din_progress";
mockIssuesApi.get.mockResolvedValue(createIssue({
ancestors: [{ id: "parent-1", identifier: "PAP-0", title: "Parent task" }] as Issue["ancestors"],
}));
await act(async () => { renderIssueView(); });
await flushReact();
const parentCrumb = mockSetBreadcrumbs.mock.calls.at(-1)![0][1];
expect(parentCrumb.onClick).toBeTypeOf("function");
parentCrumb.onClick();
const expected = { label: source === "query" ? "Inbox" : "Mine", href: state.issueDetailBreadcrumb!.href };
expect(readIssueDetailBreadcrumb("PAP-0", null)).toEqual(expected);
mockRouteParams.issueId = "PAP-0";
mockLocation.pathname = "/issues/PAP-0";
mockLocation.state = null;
mockLocation.search = "";
mockIssuesApi.get.mockResolvedValue(createIssue({ id: "parent-1", identifier: "PAP-0", title: "Parent task" }));
await act(async () => { renderIssueView(); });
await waitForAssertion(() => {
expect(mockSetBreadcrumbs.mock.calls.at(-1)![0][0]).toEqual(expected);
});
});
it.each([false, true])("renders a noninteractive private parent with mobile=%s", async (isMobile) => {
mockSidebarState.isMobile = isMobile;
mockIssuesApi.get.mockResolvedValue(createIssue({
ancestors: [{ id: "private-parent", identifier: "PAP-0", locked: true }] as unknown as Issue["ancestors"],
}));
await act(async () => { renderIssueView(); });
await flushReact();
expect(mockSetBreadcrumbs).toHaveBeenLastCalledWith([
{ label: "Tasks", href: "/issues" },
expect.objectContaining({ label: "Private task", identifier: "PAP-0", href: undefined }),
expect.objectContaining({ label: "Issue detail smoke" }),
]);
const parentCrumb = mockSetBreadcrumbs.mock.calls.at(-1)![0][1];
expect(parentCrumb.onClick).toBeUndefined();
await act(async () => { renderIssueView(parentCrumb.control); });
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
expect(chip).not.toBeNull();
expect(chip?.textContent).toBe(isMobile ? "Private" : "PAP-0");
expect(container.querySelector('a, [role="link"]')).toBeNull();
});
it("updates the private parent label when an open task changes viewport", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
ancestors: [{ id: "private-parent", identifier: "PAP-0", locked: true }] as unknown as Issue["ancestors"],
}));
const captured: Breadcrumb[][] = [];
for (const isMobile of [false, true, false]) {
mockSidebarState.isMobile = isMobile;
await act(async () => { renderIssueView(); });
await flushReact();
captured.push(mockSetBreadcrumbs.mock.calls.at(-1)![0]);
}
const { BreadcrumbProvider, useBreadcrumbs } = await vi.importActual<
typeof import("../context/BreadcrumbContext")
>("../context/BreadcrumbContext");
let updateBreadcrumbs: (crumbs: Breadcrumb[]) => void = () => {};
function RenderBreadcrumbControl() {
const { breadcrumbs, setBreadcrumbs } = useBreadcrumbs();
updateBreadcrumbs = setBreadcrumbs;
return breadcrumbs[1]?.control;
}
await act(async () => {
renderIssueView(<BreadcrumbProvider><RenderBreadcrumbControl /></BreadcrumbProvider>);
});
for (const [index, expected] of ["PAP-0", "Private", "PAP-0"].entries()) {
await act(async () => { updateBreadcrumbs(captured[index]); });
expect(container.querySelector('[data-testid="locked-issue-chip"]')?.textContent).toBe(expected);
expect(container.querySelector('a, [role="link"]')).toBeNull();
}
});
it("preserves hierarchy breadcrumbs and label chips when Streamlined UI is off", async () => {
+27
View File
@@ -5951,6 +5951,18 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
const conversationAgent = conversation?.agent ?? agents?.find(agent => agent.id === issue?.conversationAgentId);
const breadcrumbParent = issue?.ancestors?.[0];
const breadcrumbParentRef = breadcrumbParent && !isLockedIssueStub(breadcrumbParent)
? breadcrumbParent.identifier ?? breadcrumbParent.id
: null;
const rememberBreadcrumbParent = useCallback(() => {
if (!breadcrumbParentRef) return;
rememberIssueDetailLocationState(
breadcrumbParentRef,
resolvedIssueDetailState ?? location.state,
location.search,
);
}, [breadcrumbParentRef, resolvedIssueDetailState, location.state, location.search]);
useEffect(() => {
if (conversationAgent) {
setBreadcrumbs([{ label: "Chat" }]);
@@ -5958,6 +5970,18 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}
setBreadcrumbs([
sourceBreadcrumb,
...(breadcrumbParent ? [{
label: isLockedIssueStub(breadcrumbParent) ? "Private task" : breadcrumbParent.title,
identifier: breadcrumbParent.identifier ?? undefined,
href: isLockedIssueStub(breadcrumbParent)
? undefined
: createIssueDetailPath(breadcrumbParent.identifier ?? breadcrumbParent.id),
onClick: isLockedIssueStub(breadcrumbParent) ? undefined : rememberBreadcrumbParent,
control: isLockedIssueStub(breadcrumbParent)
? <LockedIssueChip identifier={isMobile ? null : breadcrumbParent.identifier} />
: undefined,
controlKey: isLockedIssueStub(breadcrumbParent) ? `private-parent:${isMobile}` : undefined,
}] : []),
{
// The status glyph (leading) already conveys in-progress/live state;
// no redundant 🔵 emoji prefix on the title.
@@ -5972,6 +5996,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
coordinatorWorkspaceEnabled,
breadcrumbTitle,
breadcrumbIdentifier,
breadcrumbParent,
isMobile,
rememberBreadcrumbParent,
hasLiveRuns,
setBreadcrumbs,
sourceBreadcrumb.href,
@@ -0,0 +1,125 @@
import { useEffect } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { BreadcrumbBar } from "@/components/BreadcrumbBar";
import { LockedIssueChip } from "@/components/LockedIssueChip";
import { StatusIcon } from "@/components/StatusIcon";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useSidebar } from "@/context/SidebarContext";
import { useLocation } from "@/lib/router";
import { PluginLauncherProvider } from "@/plugins/launchers";
function TaskBreadcrumbPreview({
parentTitle,
title,
showParent = true,
privateParent = false,
source = "Tasks",
}: {
parentTitle: string;
title: string;
showParent?: boolean;
privateParent?: boolean;
source?: "Tasks" | "Mine";
}) {
const { setBreadcrumbs } = useBreadcrumbs();
const { isMobile } = useSidebar();
const location = useLocation();
useEffect(() => {
setBreadcrumbs([
{ label: source, href: source === "Mine" ? "/inbox" : "/issues" },
...(showParent ? [{
label: privateParent ? "Private task" : parentTitle,
identifier: "PAP-120",
href: privateParent ? undefined : "/issues/PAP-120",
control: privateParent ? <LockedIssueChip identifier={isMobile ? null : "PAP-120"} /> : undefined,
controlKey: privateParent ? `private-parent:${isMobile}` : undefined,
}] : []),
{ label: title, identifier: "PAP-121", leading: <StatusIcon status="in_progress" className="size-3" />, leadingKey: "in_progress" },
]);
}, [parentTitle, title, showParent, privateParent, source, isMobile, setBreadcrumbs]);
return (
<div className="min-h-dvh w-full bg-background text-foreground">
<BreadcrumbBar taskDetailLayout />
<main className="mx-auto flex max-w-3xl flex-col gap-4 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Task breadcrumb preview</p>
<h1 className="text-xl font-semibold">{title}</h1>
{showParent && !privateParent ? <p className="text-sm text-muted-foreground">Parent: {parentTitle}</p> : null}
<p className="text-sm text-muted-foreground">Breadcrumb titles show up to 48 characters, including the ellipsis. Narrow screens shorten both titles further. Hover a breadcrumb to read its full title; screen readers retain the full name.</p>
<output className="text-xs text-muted-foreground" data-testid="navigation-destination">Current route: {location.pathname}</output>
</main>
</div>
);
}
const meta = {
id: "task-parent-breadcrumbs",
title: "Tasks and conversations/Delegation, dependencies, and signoff/Parent task breadcrumbs",
component: TaskBreadcrumbPreview,
decorators: [(Story) => <PluginLauncherProvider><Story /></PluginLauncherProvider>],
parameters: {
layout: "fullscreen",
initialEntries: ["/PAP/issues/PAP-121"],
waitForViewport: true,
paperclip: {
capabilities: ["task-coordination/delegation", "navigation-preferences/navigation"],
status: "current",
implementation: "production",
boundary: "fixtures",
notes: "Mounts the shipped BreadcrumbBar with fixture breadcrumb context. Covers linked immediate parent, long titles, private parent, standalone task, Mine source, light/dark themes and responsive widths. Page data loading and parent authorization are outside this isolated header story.",
},
},
args: { parentTitle: "Prepare the product launch", title: "Review the release checklist" },
globals: { theme: "light" },
} satisfies Meta<typeof TaskBreadcrumbPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Subtask: Story = {
play: async ({ canvasElement }) => {
const nav = within(await within(canvasElement).findByRole("navigation", { name: "breadcrumb" }));
await expect(nav.getByRole("link", { name: "Prepare the product launch PAP-120" })).toHaveAttribute("href", "/PAP/issues/PAP-120");
await expect(nav.getByRole("link", { name: "Review the release checklist PAP-121" })).toHaveAttribute("aria-current", "page");
},
};
export const LongTitles: Story = {
args: {
parentTitle: "Prepare the international product launch and coordinate release readiness across all teams",
title: "Review the release checklist and verify every customer migration before the launch window opens",
},
play: async ({ canvasElement }) => {
const nav = await within(canvasElement).findByRole("navigation", { name: "breadcrumb" });
for (const title of nav.querySelectorAll('[data-slot="breadcrumb-item"] .truncate')) {
await expect(Array.from(title.textContent ?? "").length).toBeLessThanOrEqual(48);
}
await expect(within(nav).getByRole("link", { name: /Prepare the international product launch/ })).toHaveAttribute("title", "Prepare the international product launch and coordinate release readiness across all teams");
},
};
export const Mobile: Story = { ...LongTitles, globals: { viewport: { value: "mobile", isRotated: false } } };
export const SmallMobile: Story = { ...LongTitles, globals: { viewport: { value: "mobile1", isRotated: false } } };
export const Tablet: Story = { ...LongTitles, globals: { viewport: { value: "tablet", isRotated: false } } };
export const Dark: Story = { ...LongTitles, globals: { theme: "dark" } };
export const StandaloneTask: Story = { args: { showParent: false } };
export const FromMine: Story = { args: { source: "Mine" } };
export const PrivateParent: Story = {
args: { privateParent: true },
play: async ({ canvasElement }) => {
const nav = await within(canvasElement).findByRole("navigation", { name: "breadcrumb" });
await expect(within(nav).queryByRole("link", { name: /Prepare the product launch/ })).not.toBeInTheDocument();
await expect(within(nav).queryByRole("link", { name: /private/i })).not.toBeInTheDocument();
await expect(within(nav).getByTestId("locked-issue-chip")).toBeVisible();
await expect(nav.querySelectorAll('[aria-current="page"]')).toHaveLength(1);
},
};
export const PrivateParentMobile: Story = {
...PrivateParent,
globals: { viewport: { value: "mobile1", isRotated: false } },
play: async (context) => {
await PrivateParent.play!(context);
const nav = await within(context.canvasElement).findByRole("navigation", { name: "breadcrumb" });
await expect(within(nav).getByTestId("locked-issue-chip")).toHaveTextContent("Private");
await expect(nav).not.toHaveTextContent("PAP-120");
await expect(nav).toHaveTextContent("PAP-121");
},
};