fix(ui): show parent breadcrumb titles without task IDs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
cryppadottaandPaperclip committed 2026-11-11 12:24:36 +00:00
1 parent a4acfc9c92
commit c848073ee1
5 files changed
+28 -23

No files matched your search

+3 -2
View File
@@ -847,8 +847,9 @@ 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.
Parent breadcrumbs show only the task title, with no identifier, at every width.
Mobile omits the collection link to preserve both titles and the current task
identifier. Private parents show a noninteractive Private label without an identifier.
The local task detail also offers a **Thread preview** selector. DES-1 opens a
complete synthetic conversation matching the staging transcript layout; other
+4 -3
View File
@@ -82,7 +82,7 @@ function TaskBreadcrumbs({
useEffect(() => {
setBreadcrumbs([
{ label: "Tasks", href: sourceHref },
...(parentTitle ? [{ label: parentTitle, identifier: "PAP-120", href: "/issues/PAP-120" }] : []),
...(parentTitle ? [{ label: parentTitle, href: "/issues/PAP-120" }] : []),
{
label: title,
identifier,
@@ -273,8 +273,9 @@ describe("BreadcrumbBar", () => {
<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(parent?.textContent).toBe("Prepare the engineering team");
expect(parent?.querySelector('[data-slot="task-title-identifier"]')).toBeNull();
expect(parent?.getAttribute("aria-label")).toBe("Prepare the engineering team");
expect(container.querySelector('[aria-current="page"]')?.textContent).toContain("PAP-16679");
if (mobile) {
expect(container.querySelector('a[href="/issues"]')).toBeNull();
+11 -6
View File
@@ -2923,7 +2923,7 @@ describe("IssueDetail", () => {
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: "Parent task visible in Properties", href: "/issues/PAP-0" }),
expect.objectContaining({ label: "Issue detail smoke" }),
]);
});
@@ -2939,6 +2939,8 @@ describe("IssueDetail", () => {
await act(async () => { renderIssueView(); });
await flushReact();
const parentCrumb = mockSetBreadcrumbs.mock.calls.at(-1)![0][1];
expect(parentCrumb.identifier).toBeUndefined();
expect(parentCrumb.label).toBe("Parent task");
expect(parentCrumb.onClick).toBeTypeOf("function");
parentCrumb.onClick();
const expected = { label: source === "query" ? "Inbox" : "Mine", href: state.issueDetailBreadcrumb!.href };
@@ -2951,7 +2953,9 @@ describe("IssueDetail", () => {
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);
const breadcrumbs = mockSetBreadcrumbs.mock.calls.at(-1)![0];
expect(breadcrumbs[0]).toEqual(expected);
expect(breadcrumbs.at(-1)?.label).toBe(parentCrumb.label);
});
});
@@ -2964,19 +2968,20 @@ describe("IssueDetail", () => {
await flushReact();
expect(mockSetBreadcrumbs).toHaveBeenLastCalledWith([
{ label: "Tasks", href: "/issues" },
expect.objectContaining({ label: "Private task", identifier: "PAP-0", href: undefined }),
expect.objectContaining({ label: "Private task", href: undefined }),
expect.objectContaining({ label: "Issue detail smoke" }),
]);
const parentCrumb = mockSetBreadcrumbs.mock.calls.at(-1)![0][1];
expect(parentCrumb.identifier).toBeUndefined();
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(chip?.textContent).toBe("Private");
expect(container.querySelector('a, [role="link"]')).toBeNull();
});
it("updates the private parent label when an open task changes viewport", async () => {
it("keeps the private parent title free of IDs 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"],
}));
@@ -3000,7 +3005,7 @@ describe("IssueDetail", () => {
await act(async () => {
renderIssueView(<BreadcrumbProvider><RenderBreadcrumbControl /></BreadcrumbProvider>);
});
for (const [index, expected] of ["PAP-0", "Private", "PAP-0"].entries()) {
for (const [index, expected] of ["Private", "Private", "Private"].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();
+2 -3
View File
@@ -5994,15 +5994,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
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} />
? <LockedIssueChip identifier={null} />
: undefined,
controlKey: isLockedIssueStub(breadcrumbParent) ? `private-parent:${isMobile}` : undefined,
controlKey: isLockedIssueStub(breadcrumbParent) ? "private-parent" : undefined,
}] : []),
{
// The status glyph (leading) already conveys in-progress/live state;
@@ -5,7 +5,6 @@ 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";
@@ -23,21 +22,19 @@ function TaskBreadcrumbPreview({
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,
control: privateParent ? <LockedIssueChip identifier={null} /> : undefined,
controlKey: privateParent ? "private-parent" : undefined,
}] : []),
{ label: title, identifier: "PAP-121", leading: <StatusIcon status="in_progress" className="size-3" />, leadingKey: "in_progress" },
]);
}, [parentTitle, title, showParent, privateParent, source, isMobile, setBreadcrumbs]);
}, [parentTitle, title, showParent, privateParent, source, setBreadcrumbs]);
return (
<div className="min-h-dvh w-full bg-background text-foreground">
@@ -67,7 +64,7 @@ const meta = {
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.",
notes: "Mounts the shipped BreadcrumbBar with fixture breadcrumb context. Covers title-only 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" },
@@ -79,7 +76,8 @@ 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: "Prepare the product launch" })).toHaveAttribute("href", "/PAP/issues/PAP-120");
await expect(nav.getByRole("link", { name: "Prepare the product launch" })).toHaveTextContent(/^Prepare the product launch$/);
await expect(nav.getByRole("link", { name: "Review the release checklist PAP-121" })).toHaveAttribute("aria-current", "page");
},
};
@@ -108,7 +106,8 @@ export const PrivateParent: Story = {
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(within(nav).getByTestId("locked-issue-chip")).toHaveTextContent(/^Private$/);
await expect(nav).not.toHaveTextContent("PAP-120");
await expect(nav.querySelectorAll('[aria-current="page"]')).toHaveLength(1);
},
};