feat(ui): show task identifier in task-detail breadcrumb header (#10933)

## 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 <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-08-05 19:14:51 -07:00 committed by GitHub
parent ac3b2e1d7a
commit dc71fef6bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 74 additions and 6 deletions

View File

@ -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 <span className="shrink-0 font-mono text-muted-foreground">{identifier}</span>;
}
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() {
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
{menuButton}
<div className="min-w-0 overflow-hidden flex-1">
{breadcrumbs[0].leading ? (
{breadcrumbs[0].leading || breadcrumbs[0].identifier ? (
<h1 className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wider">
<span className="flex shrink-0 items-center">{breadcrumbs[0].leading}</span>
{breadcrumbs[0].leading && (
<span className="flex shrink-0 items-center">{breadcrumbs[0].leading}</span>
)}
<CrumbIdentifier identifier={breadcrumbs[0].identifier} />
<span className="truncate">{breadcrumbs[0].label}</span>
</h1>
) : (
@ -112,9 +121,12 @@ export function BreadcrumbBar() {
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
{isLast || !crumb.href ? (
crumb.leading ? (
crumb.leading || crumb.identifier ? (
<BreadcrumbPage className="flex min-w-0 items-center gap-1.5">
<span className="flex shrink-0 items-center">{crumb.leading}</span>
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
<CrumbIdentifier identifier={crumb.identifier} />
<span className="truncate">{crumb.label}</span>
</BreadcrumbPage>
) : (
@ -122,9 +134,12 @@ export function BreadcrumbBar() {
)
) : (
<BreadcrumbLink asChild>
{crumb.leading ? (
{crumb.leading || crumb.identifier ? (
<Link to={crumb.href} className="flex items-center gap-1.5">
<span className="flex shrink-0 items-center">{crumb.leading}</span>
{crumb.leading && (
<span className="flex shrink-0 items-center">{crumb.leading}</span>
)}
<CrumbIdentifier identifier={crumb.identifier} />
<span className="truncate">{crumb.label}</span>
</Link>
) : (

View File

@ -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(
<BreadcrumbProvider>
<TestConsumer />
</BreadcrumbProvider>,
);
});
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",

View File

@ -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;

View File

@ -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,