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:
parent
cf73f3481c
commit
c7887c73ca
5 changed files with 127 additions and 69 deletions
|
|
@ -659,3 +659,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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
108
ui/src/components/ChatAgentSelector.tsx
Normal file
108
ui/src/components/ChatAgentSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue