mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## 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>
134 lines
4.6 KiB
TypeScript
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;
|
|
}
|