Files
PaperClipAI/ui/src/components/AgentChatPicker.tsx
T
DottaandPaperclip 3d5878980b fix(ui): show agent avatars in the chat picker (#18709)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Chat lets a person choose an agent for a conversation.
> - The shared agent picker shows a role icon by default.
> - An icon does not show the agent's saved visual identity.
> - This pull request uses the shared avatar component in that picker.
> - The picker now matches the avatar in the agent profile and chat
sidebar.

## Linked Issues or Issue Description

**What happened?**

The chat header switcher and main sidebar picker show agent role icons.
The conversation sidebar already supplies avatars through its existing
render option.

**Expected behavior**

All chat pickers show the agent's saved avatar. Uploaded images and
default appearances use the same shared component.

**Steps to reproduce**

1. Open an agent conversation.
2. Use the agent switcher in the chat header.
3. Compare each choice with the agent's profile avatar.

Related work: [chat header profile
navigation](https://github.com/paperclipai/paperclip/pull/17169) changes
profile links. [Task list
avatars](https://github.com/paperclipai/paperclip/pull/18607) changes a
separate surface.

## What Changed

- Use `AgentAvatar` at size 32 as the default in `AgentChatPicker`.
- Keep the existing custom render option and selection behavior.
- Test custom and default avatars inside the picker. Test the custom
render option.
- Keep the feature map unchanged for this small UI fix.

## Verification

- Passed: focused picker, avatar, sidebar, and chat page tests (36 tests
across 6 files).
- Regression proof: the new picker avatar test fails with the original
icon rendering and passes with this fix.
- Passed: `pnpm --filter @paperclipai/ui typecheck`.
- Passed: `pnpm --filter @paperclipai/ui build`.
- Passed: `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and
`git diff --check`.
- Full local typecheck and build could not finish because this
environment has no Rust `cargo` toolchain.
- The full test run was stopped after concurrent validation exceeded the
workspace memory limit. The first UI build and local server were killed.
The UI build passed when run on its own. Focused tests passed before the
full run.
- Browser verification passed against a fresh local source-checkout
instance with real API responses and embedded PostgreSQL. No agent runs
were started. Chromium checked header and sidebar pickers, an uploaded
custom image, role search, Enter selection, reload, and a 390-pixel
mobile viewport. No page errors occurred.
- Desktop, mobile, and sidebar screenshots were captured. They are not
committed to the repository.

- Previous head `9bf268c308e51113b0a10fb1314444cc99882b04`: all CI
checks passed; Greptile was 5/5 with no outstanding findings.
- Head `4e85eedd155ccf1624233009905d5ebfe8c3849b`: all 36 focused tests,
UI typecheck/build, token gates, map checks and whitespace checks passed
again after merging master. Fresh review gave 4/5 because this small fix
added feature-map detail.
- Current head `e13af2d5650e95ff552b1bfe392820c490eccf23`: removed all
four added feature-map lines. The map matches the base version. Map
checks and whitespace checks pass. The code and tests did not change.
All 30 check runs are complete: 25 passed, 4 were skipped, and 1 was
neutral. Build, typecheck, tests, all eight E2E shards, and both CI
summary gates passed. The legacy commit status passed. Greptile gave 5/5
with no open findings. All review threads are resolved. The separate
Paperclip review ended early with a neutral check; its prior map finding
is fixed.

## Risks

- Low risk. The change uses the existing avatar component and its
image-error fallback. Picker rows now use the same avatar size as the
conversation sidebar.

## Model Used

- OpenAI Codex, GPT-6, with reasoning, repository editing, shell tools,
and browser verification. The runtime does not expose a more specific
model ID or context window size.

## 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
#123` 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>
2026-11-03 08:54:08 -06:00

117 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { Agent } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
export interface AgentChatPickerProps {
agents: Agent[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (agent: Agent, signal?: AbortSignal) => void | Promise<void>;
loading?: boolean;
error?: Error | null;
onRetry?: () => void;
existingChatAgentIds?: readonly string[];
renderAgentIcon?: (agent: Agent) => ReactNode;
trigger?: ReactNode;
}
export function AgentChatPicker({ open, onOpenChange, trigger, ...props }: AgentChatPickerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent aria-describedby={undefined} className="gap-0 overflow-hidden p-0 sm:max-w-md">
<div className="px-4 pt-4 pb-3">
<DialogTitle>Chat with an agent</DialogTitle>
{props.existingChatAgentIds && <p className="mt-2 text-sm text-muted-foreground">One conversation per agent. Pick up where you left off.</p>}
</div>
{/* The dialog unmounts its content on close, so each search starts empty. */}
<AgentChatPickerResults key={String(open)} {...props} onComplete={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}
function AgentChatPickerResults({ agents, onSelect, onComplete, loading, error, onRetry, existingChatAgentIds, renderAgentIcon }: Omit<AgentChatPickerProps, "open" | "onOpenChange"> & { onComplete: () => void }) {
const mounted = useRef(false);
const selection = useRef<AbortController | null>(null);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; selection.current?.abort(); };
}, []);
const [search, setSearch] = useState("");
const [openingId, setOpeningId] = useState<string | null>(null);
const [selectionError, setSelectionError] = useState<string | null>(null);
async function selectAgent(agent: Agent) {
if (openingId) return;
setOpeningId(agent.id);
setSelectionError(null);
const request = new AbortController();
selection.current = request;
try {
await onSelect(agent, request.signal);
if (mounted.current) onComplete();
} catch (error) {
setSelectionError(error instanceof Error ? error.message : "Couldn’t open chat. Try again.");
} finally {
setOpeningId(null);
}
}
return (
<Command>
<CommandInput
aria-label="Search agents by name or role"
placeholder="Search by name or role…"
value={search}
onValueChange={setSearch}
/>
{selectionError && <p role="alert" className="px-4 py-3 text-sm text-destructive">{selectionError}</p>}
{openingId && <p role="status" className="sr-only">Opening conversation…</p>}
{error ? (
<div role="alert" className="flex flex-col items-start gap-2 p-4 text-sm">
<p>Couldn’t load agents. Try again.</p>
{onRetry && <Button variant="outline" size="sm" onClick={onRetry}>Retry</Button>}
</div>
) : loading ? (
<p role="status" className="p-4 text-sm text-muted-foreground">Loading agents…</p>
) : (
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 px-4">
<span>{agents.length ? `No agents match “${search}”` : "No agents yet."}</span>
{agents.length ? <>
<span className="text-xs text-muted-foreground">Try another name or role.</span>
<Button variant="ghost" size="sm" onClick={() => setSearch("")}>Clear search</Button>
</> : <span className="text-xs text-muted-foreground">Create an agent from the Agents page to start chatting.</span>}
</div>
</CommandEmpty>
<CommandGroup>
{agents.map((agent) => (
<CommandItem
key={agent.id}
value={agent.id}
keywords={[agent.name, agent.title ?? "", agent.role]}
onSelect={() => { void selectAgent(agent); }}
disabled={openingId !== null}
className="gap-3 px-3 py-3"
>
{renderAgentIcon ? renderAgentIcon(agent) : <AgentAvatar agent={agent} size={32} />}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{agent.name}</span>
<span className="truncate text-xs text-muted-foreground">{agent.title ?? agent.role}</span>
</span>
{existingChatAgentIds && <span className="shrink-0 text-xs text-muted-foreground">{existingChatAgentIds.includes(agent.id) ? "Open chat" : "New chat"}</span>}
{agent.status === "paused" && <span className="text-xs text-(--status-agent-paused)">Paused</span>}
{agent.status === "terminated" && <span className="text-xs text-muted-foreground">Terminated</span>}
{agent.status === "pending_approval" && <span className="text-xs text-muted-foreground">Awaiting approval</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
)}
</Command>
);
}