Files
PaperClipAI/ui/src/context/BreadcrumbContext.tsx
T
DottaandPaperclip 44a9a043b8 fix(ui): make the full chat agent selector clickable (#18708)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat gives each person a standing conversation with an agent.
> - The Coordinator chat header shows an agent avatar, name, and caret.
> - Only the caret opened the agent picker. The avatar and name were
separate text.
> - This pull request puts all three parts in one button that opens the
existing picker.
> - The benefit is a larger click target with one keyboard focus stop.

## Linked Issues or Issue Description

**What happened?**

In Coordinator Chat, the caret opened the agent picker. The agent avatar
and name did not open it.

**Expected behavior**

The avatar, name, and caret must form one control. Each part must open
the same picker.

**Steps to reproduce**

1. Enable the personal Coordinator workspace and open an agent chat.
2. Click the agent avatar or name in the header.
3. Compare the result with a click on the caret.

## What Changed

- Put the avatar, name, and caret inside one button in
`CoordinatorAgentSwitcher`. Keep its current-page marker for screen
readers.
- Add an optional trigger to `AgentChatPicker`. The shared dialog
handles keyboard access and focus return.
- Add an optional breadcrumb control slot. The chat owns its button,
while the text label still sets the document title.
- Add regression cases for both header layouts on desktop and mobile.
Check all three click areas, Escape, focus return, and selection of
another agent.
- Add a Coordinator chat Storybook case with production components and
fixture API data. Update the feature recipe.

## Verification

- Passed 235 focused tests for `BreadcrumbBar`, `AgentChatPicker`,
`BreadcrumbContext`, and `IssueDetail`. The header cases also check the
current-page marker.
- Passed UI typecheck and UI production build.
- Passed token gates, map checks, and Storybook catalog checks. All 30
map and catalog regression tests passed.
- The new header cases failed before the change and passed after it.
- Full local typecheck could not finish because this workspace has no
`cargo` command.
- Full local build and the first UI typecheck attempt reached the 8 GB
workspace memory limit. The separate UI build and typecheck passed. The
full local test run was stopped to free memory for browser verification;
it has no complete verdict.
- Passed 18 browser checks in Chromium across desktop light, mobile
light, and desktop dark. Each case checks avatar/name/caret clicks,
Enter, Space, Escape/focus return, and selection of QAChecker. No page
render errors occurred.
- Captured six screenshots of the header and picker across these
viewports and themes. Review images remain outside the repository.
- Browser review first found stale fixture responses for recent
comments, decision receipts, chat handoffs, and ordinary-task email
state. Correct API response shapes removed those fixture render failures
before the successful review.

## Risks

- Low risk. The shared breadcrumb renderer has a new optional control
slot. Callers that omit it retain their existing rendering.
- Chat selection uses the existing picker and route. No database or API
contract changes are required.
- The Storybook API is simulated. It does not prove provider execution
or server persistence.

## Model Used

OpenAI Codex, GPT-6. The runtime did not expose the exact deployed model
ID or context window. The agent used code editing, terminal checks, and
browser automation.

## 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
#` 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: Paperclip <noreply@paperclip.ing>
2026-11-03 07:20:20 -06:00

134 lines
4.6 KiB
TypeScript

import { createContext, useCallback, useContext, useEffect, useState, type ReactNode, type MouseEventHandler } from "react";
export interface Breadcrumb {
label: string;
href?: string;
/** Optional handler for preserving local work before navigating. */
onClick?: MouseEventHandler<HTMLAnchorElement>;
/**
* 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;
/**
* Stable identity for `leading` so equality/diffing works without comparing
* React nodes by reference (which always differ across renders). Set this to
* a primitive that changes only when the rendered `leading` should change.
*/
leadingKey?: string;
/** Optional action beside the label, outside the breadcrumb link. */
trailing?: ReactNode;
/** Stable identity for the action, following leadingKey semantics. */
trailingKey?: string;
/** Optional control that replaces the visible label and its leading/trailing nodes. */
control?: ReactNode;
/** Stable identity for the control, following leadingKey semantics. */
controlKey?: string;
}
interface BreadcrumbContextValue {
breadcrumbs: Breadcrumb[];
setBreadcrumbs: (crumbs: Breadcrumb[]) => void;
breadcrumbToolbar: ReactNode | null;
setBreadcrumbToolbar: (node: ReactNode | null) => void;
breadcrumbPanelControl: BreadcrumbPanelControl | null;
setBreadcrumbPanelControl: (control: BreadcrumbPanelControl | null) => void;
mobileToolbar: ReactNode | null;
setMobileToolbar: (node: ReactNode | null) => void;
}
export interface BreadcrumbPanelControl {
open: boolean;
onToggle: () => void;
}
interface BreadcrumbProviderProps {
children: ReactNode;
companyName?: string | null;
}
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (
left[index]?.label !== right[index]?.label
|| left[index]?.onClick !== right[index]?.onClick
|| left[index]?.href !== right[index]?.href
|| left[index]?.identifier !== right[index]?.identifier
|| left[index]?.leadingKey !== right[index]?.leadingKey
|| left[index]?.trailingKey !== right[index]?.trailingKey
|| left[index]?.controlKey !== right[index]?.controlKey
) {
return false;
}
}
return true;
}
export function buildDocumentTitle(breadcrumbs: Breadcrumb[], companyName?: string | null) {
const pageParts = breadcrumbs.length === 0
? []
: [...breadcrumbs].reverse().map((breadcrumb) => breadcrumb.label);
const companyPart = companyName?.trim() ? [companyName.trim()] : [];
const parts = [...pageParts, ...companyPart, "Paperclip"];
return parts.join(" • ");
}
export function BreadcrumbProvider({ children, companyName }: BreadcrumbProviderProps) {
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
const [breadcrumbToolbar, setBreadcrumbToolbarState] = useState<ReactNode | null>(null);
const [breadcrumbPanelControl, setBreadcrumbPanelControlState] =
useState<BreadcrumbPanelControl | null>(null);
const [mobileToolbar, setMobileToolbarState] = useState<ReactNode | null>(null);
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
setBreadcrumbsState((current) => (breadcrumbsEqual(current, crumbs) ? current : crumbs));
}, []);
const setMobileToolbar = useCallback((node: ReactNode | null) => {
setMobileToolbarState(node);
}, []);
const setBreadcrumbToolbar = useCallback((node: ReactNode | null) => {
setBreadcrumbToolbarState(node);
}, []);
const setBreadcrumbPanelControl = useCallback((control: BreadcrumbPanelControl | null) => {
setBreadcrumbPanelControlState(control);
}, []);
useEffect(() => {
document.title = buildDocumentTitle(breadcrumbs, companyName);
}, [breadcrumbs, companyName]);
return (
<BreadcrumbContext.Provider
value={{
breadcrumbs,
setBreadcrumbs,
breadcrumbToolbar,
setBreadcrumbToolbar,
breadcrumbPanelControl,
setBreadcrumbPanelControl,
mobileToolbar,
setMobileToolbar,
}}
>
{children}
</BreadcrumbContext.Provider>
);
}
export function useBreadcrumbs() {
const ctx = useContext(BreadcrumbContext);
if (!ctx) {
throw new Error("useBreadcrumbs must be used within BreadcrumbProvider");
}
return ctx;
}