From dc71fef6bf8dd10b5d3239b9efe71b428e426e4b Mon Sep 17 00:00:00 2001 From: scotttong Date: Wed, 5 Aug 2026 19:14:51 -0700 Subject: [PATCH] feat(ui): show task identifier in task-detail breadcrumb header (#10933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task detail page shows a breadcrumb header with the task status glyph and the task title. > - The breadcrumb did not show the task identifier, so a reader could not name the task without opening extra context. > - Agents and people refer to tasks by identifier, so the identifier belongs next to the title. > - This pull request renders the task identifier in the breadcrumb header, between the status glyph and the title. > - The benefit is faster reference: a reader sees the task key and the title together at the top of the page. ## Linked Issues or Issue Description **What existing behavior does this improve?** The task detail breadcrumb header. It shows the status glyph and the task title, but not the task identifier. **Subsystem affected** Web UI — the breadcrumb bar on the task detail page (`ui/src/components/BreadcrumbBar.tsx`, `ui/src/context/BreadcrumbContext.tsx`, `ui/src/pages/IssueDetail.tsx`). **Current behavior** The breadcrumb header renders the status glyph and then the task title. The task identifier does not appear in the header. **Proposed behavior** The breadcrumb header renders the task identifier between the status glyph and the title. The identifier uses gray monospace styling from design tokens (`font-mono text-muted-foreground`). **Reason and benefit** A reader can name and reference the task from the header without opening more context. The identifier and the title appear together. **Breaking changes** None. The identifier field is optional. Crumbs without an identifier render as before. ## What Changed - Add an optional `identifier` field to the `Breadcrumb` type and include it in the `breadcrumbsEqual` comparison so an identifier change triggers a fresh render. - Add a `CrumbIdentifier` helper in `BreadcrumbBar` that renders the identifier in gray monospace (`font-mono text-muted-foreground`), placed after the leading status glyph in each crumb variant. - Wire the issue identifier onto the task crumb in `IssueDetail`. - Add unit tests that cover the identifier field in `breadcrumbsEqual` (fresh render on change, no-op on identical value). ## Verification - `pnpm check:token-gates` → 3/3 gates CLEAN (color literals, arbitrary bracket values, raw font-size). - `pnpm --filter @paperclipai/ui exec vitest run src/context/BreadcrumbContext.test.tsx` → 4/4 tests pass. - `pnpm typecheck` → the four changed files typecheck clean. - Manual: open a task detail page. The breadcrumb header shows the status glyph, then the task identifier in gray monospace, then the title. Visual change. Snapshot baselines are intentionally not updated, per `doc/design/DECISION-SHEET.md` → "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks Low risk. The change is additive and the identifier field is optional. It touches only the breadcrumb header rendering and the equality check. No data model or API change. ## Model Used Claude Opus 4.8 (Anthropic), model id `claude-opus-4-8`, extended thinking enabled, tool use enabled. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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: Claude Opus 4.8 --- ui/src/components/BreadcrumbBar.tsx | 27 ++++++++++---- ui/src/context/BreadcrumbContext.test.tsx | 44 +++++++++++++++++++++++ ui/src/context/BreadcrumbContext.tsx | 6 ++++ ui/src/pages/IssueDetail.tsx | 3 ++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index cde8220b44..3bb3407efe 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -18,6 +18,12 @@ import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers"; type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null }; +/** Task identifier rendered in gray monospace between the glyph and the title. */ +function CrumbIdentifier({ identifier }: { identifier?: string }) { + if (!identifier) return null; + return {identifier}; +} + function GlobalToolbar({ context }: { context: GlobalToolbarContext }) { const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId }); const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId }); @@ -82,9 +88,12 @@ export function BreadcrumbBar() {
{menuButton}
- {breadcrumbs[0].leading ? ( + {breadcrumbs[0].leading || breadcrumbs[0].identifier ? (

- {breadcrumbs[0].leading} + {breadcrumbs[0].leading && ( + {breadcrumbs[0].leading} + )} + {breadcrumbs[0].label}

) : ( @@ -112,9 +121,12 @@ export function BreadcrumbBar() { {i > 0 && } {isLast || !crumb.href ? ( - crumb.leading ? ( + crumb.leading || crumb.identifier ? ( - {crumb.leading} + {crumb.leading && ( + {crumb.leading} + )} + {crumb.label} ) : ( @@ -122,9 +134,12 @@ export function BreadcrumbBar() { ) ) : ( - {crumb.leading ? ( + {crumb.leading || crumb.identifier ? ( - {crumb.leading} + {crumb.leading && ( + {crumb.leading} + )} + {crumb.label} ) : ( diff --git a/ui/src/context/BreadcrumbContext.test.tsx b/ui/src/context/BreadcrumbContext.test.tsx index ab2eb67b50..19516c92c4 100644 --- a/ui/src/context/BreadcrumbContext.test.tsx +++ b/ui/src/context/BreadcrumbContext.test.tsx @@ -59,6 +59,50 @@ describe("BreadcrumbContext", () => { expect(renderCounts).toHaveLength(2); }); + it("rerenders consumers when only the crumb identifier changes", () => { + const renderCounts: number[] = []; + let updateBreadcrumbs: + | ((crumbs: Array<{ label: string; href?: string; identifier?: string }>) => void) + | null = null; + + function TestConsumer() { + const { breadcrumbs, setBreadcrumbs } = useBreadcrumbs(); + renderCounts.push(breadcrumbs.length); + updateBreadcrumbs = setBreadcrumbs; + return null; + } + + act(() => { + root.render( + + + , + ); + }); + + expect(renderCounts).toHaveLength(1); + + act(() => { + updateBreadcrumbs?.([{ label: "First task prompt", identifier: "PAP-1204" }]); + }); + + expect(renderCounts).toHaveLength(2); + + // Same everything but a new identifier must produce a fresh render. + act(() => { + updateBreadcrumbs?.([{ label: "First task prompt", identifier: "PAP-1205" }]); + }); + + expect(renderCounts).toHaveLength(3); + + // Identical identifier is a no-op. + act(() => { + updateBreadcrumbs?.([{ label: "First task prompt", identifier: "PAP-1205" }]); + }); + + expect(renderCounts).toHaveLength(3); + }); + it("builds page titles with the selected company name before Paperclip", () => { expect(buildDocumentTitle([{ label: "Inbox" }], "Anachronist Wiki")).toBe( "Inbox • Anachronist Wiki • Paperclip", diff --git a/ui/src/context/BreadcrumbContext.tsx b/ui/src/context/BreadcrumbContext.tsx index 5c7fc43161..2726c9a904 100644 --- a/ui/src/context/BreadcrumbContext.tsx +++ b/ui/src/context/BreadcrumbContext.tsx @@ -3,6 +3,11 @@ import { createContext, useCallback, useContext, useEffect, useState, type React export interface Breadcrumb { label: string; href?: string; + /** + * Optional task identifier (e.g. "PAP-1204") rendered in gray monospace + * between the leading glyph and the label. + */ + identifier?: string; /** Optional node rendered before the label (e.g. a status glyph). */ leading?: ReactNode; /** @@ -34,6 +39,7 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) { if ( left[index]?.label !== right[index]?.label || left[index]?.href !== right[index]?.href + || left[index]?.identifier !== right[index]?.identifier || left[index]?.leadingKey !== right[index]?.leadingKey ) { return false; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 27c674bf2d..483fb2860d 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2042,6 +2042,7 @@ export function IssueDetail() { [comments, optimisticComments], ); const breadcrumbTitle = issue?.title ?? issueId ?? "Task"; + const breadcrumbIdentifier = issue?.identifier ?? issueHeaderSeed?.identifier ?? undefined; const breadcrumbStatus = issue?.status; const breadcrumbBlockerAttention = issue?.blockerAttention; // Stable identity for the breadcrumb status glyph. The glyph's shape/colour @@ -3209,12 +3210,14 @@ export function IssueDetail() { // The status glyph (leading) already conveys in-progress/live state; // no redundant 🔵 emoji prefix on the title. label: breadcrumbTitle, + identifier: breadcrumbIdentifier, leading: breadcrumbStatusLeading, leadingKey: breadcrumbStatusKey, }, ]); }, [ breadcrumbTitle, + breadcrumbIdentifier, hasLiveRuns, setBreadcrumbs, sourceBreadcrumb.href,