feat(22-02): ChatAgentSelector component with agent dropdown and role colors

- Create ChatAgentSelector with Popover+Command dropdown
- Show active agent icon, name, and ChevronDown indicator
- 'Select agent' placeholder when no agent selected
- 'No agents configured' empty state via CommandEmpty
- Agent list shows icon, name, and role label per item
- Selection calls onAgentChange and PATCHes conversation via chatApi
- Role-specific colors from agentRoleColors applied to agent icons
- Loading state shows Skeleton placeholder
- Create chat.ts API client with updateConversation supporting agentId
- Create shared types/chat.ts with ChatMessage, ChatConversation types
- Create ChatCodeBlock prerequisite from phase-21 base
- TypeScript compiles clean
This commit is contained in:
Nexus Dev 2026-04-01 18:17:34 +00:00
parent 704e9f2406
commit 8ebfd1a8c1
5 changed files with 127 additions and 69 deletions

View file

@ -618,3 +618,11 @@ export {
type SecretsLocalEncryptedConfig,
type ConfigMeta,
} from "./config-schema.js";
export {
type ChatConversation,
type ChatConversationListItem,
type ChatMessage,
type ChatConversationListResponse,
type ChatMessageListResponse,
} from "./types/chat.js";

View file

@ -29,7 +29,7 @@ export const chatApi = {
updateConversation(
id: string,
data: { title?: string; pinnedAt?: string | null; archivedAt?: string | null },
data: { title?: string; pinnedAt?: string | null; archivedAt?: string | null; agentId?: string | null },
) {
return api.patch<ChatConversation>(`/conversations/${id}`, data);
},
@ -54,56 +54,4 @@ export const chatApi = {
) {
return api.post<ChatMessage>(`/conversations/${conversationId}/messages`, data);
},
async postMessageAndStream(
conversationId: string,
data: { content: string; agentId?: string },
callbacks: {
onToken: (token: string) => void;
onDone: (messageId: string, content: string) => void;
onError: (error: string) => void;
},
signal?: AbortSignal,
) {
const res = await fetch(`/api/conversations/${conversationId}/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
credentials: "include",
signal,
});
if (!res.ok || !res.body) {
callbacks.onError("Failed to start stream");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const json = line.slice(6);
try {
const parsed = JSON.parse(json) as { token?: string; done?: boolean; messageId?: string; content?: string; error?: string };
if (parsed.token) callbacks.onToken(parsed.token);
if (parsed.done && parsed.messageId) callbacks.onDone(parsed.messageId, parsed.content ?? "");
if (parsed.error) callbacks.onError(parsed.error);
} catch { /* ignore malformed lines */ }
}
}
} catch (err) {
if (signal?.aborted) return; // Expected on stop
callbacks.onError("Stream connection lost");
}
},
async savePartialMessage(conversationId: string, data: { role: "assistant"; content: string; agentId?: string }) {
return chatApi.postMessage(conversationId, data);
},
};

View file

@ -1,6 +1,13 @@
import { describe, it } from "vitest";
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
describe("ChatAgentSelector", () => {
it("exports ChatAgentSelector component", async () => {
const mod = await import("./ChatAgentSelector");
expect(mod.ChatAgentSelector).toBeDefined();
expect(typeof mod.ChatAgentSelector).toBe("function");
});
it.todo("renders active agent icon and name when agentId is set");
it.todo("renders 'Select agent' placeholder when no agent selected");
it.todo("lists all workspace agents in dropdown");

View file

@ -0,0 +1,108 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { agentsApi } from "../api/agents";
import { chatApi } from "../api/chat";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
import type { Agent, AgentRole } from "@paperclipai/shared";
interface ChatAgentSelectorProps {
companyId: string;
conversationId: string | null;
agentId: string | null;
onAgentChange: (agentId: string | null) => void;
}
export function ChatAgentSelector({
companyId,
conversationId,
agentId,
onAgentChange,
}: ChatAgentSelectorProps) {
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { data: agents, isLoading } = useQuery({
queryKey: ["agents", companyId],
queryFn: () => agentsApi.list(companyId),
enabled: !!companyId,
});
const updateMutation = useMutation({
mutationFn: (newAgentId: string) =>
chatApi.updateConversation(conversationId!, { agentId: newAgentId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["chat", "conversations"] });
},
});
const activeAgent = agents?.find((a) => a.id === agentId);
const handleSelect = (agent: Agent) => {
onAgentChange(agent.id);
if (conversationId) {
updateMutation.mutate(agent.id);
}
setOpen(false);
};
if (isLoading) {
return <Skeleton className="h-7 w-20 rounded" />;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 max-w-[120px] gap-1 px-2 text-xs"
aria-label="Active agent"
>
{activeAgent ? (
<>
<AgentIcon
icon={activeAgent.icon}
className={`h-3.5 w-3.5 ${activeAgent.role ? (agentRoleColors[activeAgent.role as AgentRole] ?? agentRoleColorDefault) : agentRoleColorDefault}`}
/>
<span className="truncate">{activeAgent.name}</span>
</>
) : (
<span className="text-muted-foreground">Select agent</span>
)}
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandEmpty>No agents configured</CommandEmpty>
<CommandGroup>
{agents?.map((agent) => {
const colorClass = agent.role
? (agentRoleColors[agent.role as AgentRole] ?? agentRoleColorDefault)
: agentRoleColorDefault;
return (
<CommandItem
key={agent.id}
onSelect={() => handleSelect(agent)}
className="flex items-center gap-2"
>
<AgentIcon icon={agent.icon} className={`h-3.5 w-3.5 ${colorClass}`} />
<span className="truncate">{agent.name}</span>
<span className="ml-auto text-[11px] text-muted-foreground">{agent.role}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -5,9 +5,6 @@ import type { ExtraProps } from "react-markdown";
type ChatCodeBlockProps = HTMLAttributes<HTMLPreElement> & ExtraProps;
/**
* Recursively flatten React children into a plain text string.
*/
function flattenText(value: ReactNode): string {
if (value == null) return "";
if (typeof value === "string" || typeof value === "number") return String(value);
@ -19,18 +16,12 @@ function flattenText(value: ReactNode): string {
return "";
}
/**
* Extract the language name from a className like "language-typescript" -> "typescript".
*/
function extractLanguage(className?: string | null): string | null {
if (!className) return null;
const match = /\blanguage-(\w+)\b/.exec(className);
return match ? match[1] : null;
}
/**
* Get the className of the first child code element, if any.
*/
function getCodeClassName(children: ReactNode): string | null {
if (children == null) return null;
if (typeof children === "object" && "props" in (children as object)) {
@ -48,9 +39,6 @@ function getCodeClassName(children: ReactNode): string | null {
return null;
}
/**
* Check whether children contain a code element (i.e., this is a fenced code block).
*/
function hasCodeChild(children: ReactNode): boolean {
if (children == null) return false;
if (typeof children === "object" && "props" in (children as object)) {
@ -65,7 +53,7 @@ function hasCodeChild(children: ReactNode): boolean {
return false;
}
function doCopy(text: string) {
function doCopyToClipboard(text: string) {
navigator.clipboard.writeText(text).catch(() => {
// Ignore clipboard errors in restricted environments
});
@ -74,7 +62,6 @@ function doCopy(text: string) {
export function ChatCodeBlock({ children, className, ...props }: ChatCodeBlockProps) {
const [copied, setCopied] = useState(false);
// If there's no code child, render a plain <pre> without the toolbar
if (!hasCodeChild(children)) {
return (
<pre className={className} {...props}>
@ -88,7 +75,7 @@ export function ChatCodeBlock({ children, className, ...props }: ChatCodeBlockPr
const codeText = flattenText(children);
function handleCopy() {
doCopy(codeText);
doCopyToClipboard(codeText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}