mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
1 parent
8d2bf78323
commit
cac82ed4d9
7 files changed
+330
-26
No files matched your search
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user