From c848073ee142edc9cfb4858ddfc651c2f12bf14f Mon Sep 17 00:00:00 2001 From: cryppadotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:24:36 +0000 Subject: [PATCH] fix(ui): show parent breadcrumb titles without task IDs Co-Authored-By: Paperclip --- DESIGN.md | 5 +++-- ui/src/components/BreadcrumbBar.test.tsx | 7 ++++--- ui/src/pages/IssueDetail.test.tsx | 17 +++++++++++------ ui/src/pages/IssueDetail.tsx | 5 ++--- .../task-breadcrumbs.stories.tsx | 17 ++++++++--------- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a65c1444d54..3fffb292159 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/ui/src/components/BreadcrumbBar.test.tsx b/ui/src/components/BreadcrumbBar.test.tsx index c9a0f438185..1d9256cca29 100644 --- a/ui/src/components/BreadcrumbBar.test.tsx +++ b/ui/src/components/BreadcrumbBar.test.tsx @@ -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", () => { , )); const parent = container.querySelector('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(); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index d9bf712625b..1bc7cab7e9f 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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(); }); - 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(); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 9846dc0591d..ac17f30eb87 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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) - ? + ? : undefined, - controlKey: isLockedIssueStub(breadcrumbParent) ? `private-parent:${isMobile}` : undefined, + controlKey: isLockedIssueStub(breadcrumbParent) ? "private-parent" : undefined, }] : []), { // The status glyph (leading) already conveys in-progress/live state; diff --git a/ui/storybook/stories/task-coordination/task-breadcrumbs.stories.tsx b/ui/storybook/stories/task-coordination/task-breadcrumbs.stories.tsx index 384793e537f..42c09648e10 100644 --- a/ui/storybook/stories/task-coordination/task-breadcrumbs.stories.tsx +++ b/ui/storybook/stories/task-coordination/task-breadcrumbs.stories.tsx @@ -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 ? : undefined, - controlKey: privateParent ? `private-parent:${isMobile}` : undefined, + control: privateParent ? : undefined, + controlKey: privateParent ? "private-parent" : undefined, }] : []), { label: title, identifier: "PAP-121", leading: , leadingKey: "in_progress" }, ]); - }, [parentTitle, title, showParent, privateParent, source, isMobile, setBreadcrumbs]); + }, [parentTitle, title, showParent, privateParent, source, setBreadcrumbs]); return (
@@ -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; 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); }, };