497 lines
18 KiB
Markdown
497 lines
18 KiB
Markdown
---
|
|
phase: 22-agent-streaming
|
|
plan: "02"
|
|
type: execute
|
|
wave: 1
|
|
depends_on: ["22-00"]
|
|
files_modified:
|
|
- ui/src/components/ChatAgentSelector.tsx
|
|
- ui/src/components/ChatMessageIdentityBar.tsx
|
|
- ui/src/components/ChatStreamingCursor.tsx
|
|
- ui/src/components/ChatMessage.tsx
|
|
- ui/src/components/ChatAgentSelector.test.tsx
|
|
- ui/src/components/ChatMessageIdentityBar.test.tsx
|
|
autonomous: true
|
|
requirements:
|
|
- AGENT-04
|
|
- CHAT-08
|
|
- THEME-03
|
|
must_haves:
|
|
truths:
|
|
- "Every assistant message shows the agent's name and icon above the content"
|
|
- "User can switch the active agent for a conversation via a dropdown selector"
|
|
- "Agent colors are visually distinguishable using role-specific Tailwind classes with dark: variants"
|
|
artifacts:
|
|
- path: "ui/src/components/ChatAgentSelector.tsx"
|
|
provides: "Agent dropdown in ChatPanel header"
|
|
exports: ["ChatAgentSelector"]
|
|
- path: "ui/src/components/ChatMessageIdentityBar.tsx"
|
|
provides: "Agent icon + name + timestamp above assistant messages"
|
|
exports: ["ChatMessageIdentityBar"]
|
|
- path: "ui/src/components/ChatStreamingCursor.tsx"
|
|
provides: "Blinking inline cursor during streaming"
|
|
exports: ["ChatStreamingCursor"]
|
|
- path: "ui/src/components/ChatMessage.tsx"
|
|
provides: "Extended ChatMessage with identity bar, streaming cursor, hover actions"
|
|
exports: ["ChatMessage"]
|
|
key_links:
|
|
- from: "ui/src/components/ChatMessageIdentityBar.tsx"
|
|
to: "ui/src/lib/agent-role-colors.ts"
|
|
via: "import agentRoleColors"
|
|
pattern: "agentRoleColors"
|
|
- from: "ui/src/components/ChatAgentSelector.tsx"
|
|
to: "ui/src/api/agents.ts"
|
|
via: "agentsApi.list"
|
|
pattern: "agentsApi"
|
|
- from: "ui/src/components/ChatMessage.tsx"
|
|
to: "ui/src/components/ChatMessageIdentityBar.tsx"
|
|
via: "import ChatMessageIdentityBar"
|
|
pattern: "ChatMessageIdentityBar"
|
|
---
|
|
|
|
<objective>
|
|
Agent identity components: agent selector dropdown (CHAT-08), message identity bar with icon/name/timestamp (AGENT-04), streaming cursor, and role-specific colors (THEME-03). Extends ChatMessage to accept and render agent identity props.
|
|
|
|
Purpose: Make agent identity visible on every assistant message and allow users to switch agents per conversation.
|
|
Output: ChatAgentSelector, ChatMessageIdentityBar, ChatStreamingCursor components; extended ChatMessage.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/22-agent-streaming/22-RESEARCH.md
|
|
@.planning/phases/22-agent-streaming/22-UI-SPEC.md
|
|
@.planning/phases/22-agent-streaming/22-00-SUMMARY.md
|
|
|
|
<interfaces>
|
|
From ui/src/components/AgentIconPicker.tsx:
|
|
```typescript
|
|
interface AgentIconProps {
|
|
icon?: string | null;
|
|
className?: string;
|
|
}
|
|
export function AgentIcon({ icon, className }: AgentIconProps): JSX.Element;
|
|
```
|
|
|
|
From ui/src/api/agents.ts:
|
|
```typescript
|
|
export const agentsApi = {
|
|
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
|
|
// ...
|
|
};
|
|
```
|
|
|
|
From packages/shared/src/types/chat.ts:
|
|
```typescript
|
|
export interface ChatMessage {
|
|
id: string; conversationId: string;
|
|
role: "user" | "assistant" | "system";
|
|
content: string; agentId: string | null;
|
|
createdAt: string; updatedAt: string | null;
|
|
}
|
|
```
|
|
|
|
From ui/src/lib/agent-role-colors.ts (created in Plan 00):
|
|
```typescript
|
|
export const agentRoleColors: Record<AgentRole, string>;
|
|
export const agentRoleColorDefault: string;
|
|
```
|
|
|
|
From ui/src/components/ChatMessage.tsx (current):
|
|
```typescript
|
|
interface ChatMessageProps {
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
}
|
|
export function ChatMessage({ role, content }: ChatMessageProps): JSX.Element;
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: ChatMessageIdentityBar, ChatStreamingCursor, and extended ChatMessage</name>
|
|
<read_first>
|
|
- ui/src/components/ChatMessage.tsx
|
|
- ui/src/components/ChatMarkdownMessage.tsx
|
|
- ui/src/components/AgentIconPicker.tsx
|
|
- ui/src/lib/agent-role-colors.ts
|
|
- ui/src/lib/status-colors.ts
|
|
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 72-95 identity bar + streaming cursor)
|
|
</read_first>
|
|
<files>
|
|
ui/src/components/ChatMessageIdentityBar.tsx,
|
|
ui/src/components/ChatStreamingCursor.tsx,
|
|
ui/src/components/ChatMessage.tsx,
|
|
ui/src/components/ChatMessageIdentityBar.test.tsx
|
|
</files>
|
|
<action>
|
|
**1. Create `ui/src/components/ChatMessageIdentityBar.tsx`:**
|
|
|
|
```typescript
|
|
import { AgentIcon } from "./AgentIconPicker";
|
|
import { agentRoleColors, agentRoleColorDefault } from "../lib/agent-role-colors";
|
|
import type { AgentRole } from "@paperclipai/shared";
|
|
|
|
interface ChatMessageIdentityBarProps {
|
|
agentName: string;
|
|
agentIcon?: string | null;
|
|
agentRole?: AgentRole | null;
|
|
timestamp?: string;
|
|
isStreaming?: boolean;
|
|
}
|
|
|
|
export function ChatMessageIdentityBar({
|
|
agentName,
|
|
agentIcon,
|
|
agentRole,
|
|
timestamp,
|
|
isStreaming,
|
|
}: ChatMessageIdentityBarProps) {
|
|
const colorClass = agentRole ? (agentRoleColors[agentRole] ?? agentRoleColorDefault) : agentRoleColorDefault;
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<AgentIcon icon={agentIcon} className={`h-4 w-4 ${colorClass}`} />
|
|
<span className={`text-[13px] font-semibold ${colorClass}`}>{agentName}</span>
|
|
{isStreaming && (
|
|
<span className="h-1.5 w-1.5 rounded-full bg-cyan-400 animate-pulse" />
|
|
)}
|
|
{timestamp && (
|
|
<span className="text-[11px] text-muted-foreground">
|
|
{new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Per UI spec: icon 16x16 (`h-4 w-4`), name 13px semibold, timestamp 11px muted, streaming dot uses `bg-cyan-400 animate-pulse` from `agentStatusDot.running`.
|
|
|
|
**2. Create `ui/src/components/ChatStreamingCursor.tsx`:**
|
|
|
|
```typescript
|
|
export function ChatStreamingCursor() {
|
|
return (
|
|
<span
|
|
className="inline-block w-2 h-[1em] bg-foreground/70 animate-cursor-blink ml-0.5 align-text-bottom"
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
Per UI spec: `w-2 h-[1em] bg-foreground/70 animate-cursor-blink`, `aria-hidden="true"` (decorative only).
|
|
|
|
**3. Extend `ChatMessage` props and rendering in `ui/src/components/ChatMessage.tsx`:**
|
|
|
|
Update the `ChatMessageProps` interface to:
|
|
```typescript
|
|
interface ChatMessageProps {
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
agentName?: string | null;
|
|
agentIcon?: string | null;
|
|
agentRole?: AgentRole | null;
|
|
timestamp?: string;
|
|
isStreaming?: boolean;
|
|
}
|
|
```
|
|
|
|
Import `AgentRole` from `@paperclipai/shared`, `ChatMessageIdentityBar`, and `ChatStreamingCursor`.
|
|
|
|
Update the assistant/system rendering branch to:
|
|
```tsx
|
|
return (
|
|
<div className="max-w-full group relative">
|
|
{agentName && (
|
|
<ChatMessageIdentityBar
|
|
agentName={agentName}
|
|
agentIcon={agentIcon}
|
|
agentRole={agentRole}
|
|
timestamp={timestamp}
|
|
isStreaming={isStreaming}
|
|
/>
|
|
)}
|
|
<ChatMarkdownMessage content={content} />
|
|
{isStreaming && <ChatStreamingCursor />}
|
|
</div>
|
|
);
|
|
```
|
|
|
|
The `group` class enables hover-reveal for edit/retry buttons (Plan 03). User message branch remains unchanged for now (edit action is Plan 03).
|
|
|
|
**4. Replace test stubs in `ui/src/components/ChatMessageIdentityBar.test.tsx`** with real tests:
|
|
```typescript
|
|
// @vitest-environment jsdom
|
|
import { describe, it, expect } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import { ChatMessageIdentityBar } from "./ChatMessageIdentityBar";
|
|
|
|
describe("ChatMessageIdentityBar", () => {
|
|
it("renders agent name in semibold text", () => {
|
|
render(<ChatMessageIdentityBar agentName="PM Agent" />);
|
|
expect(screen.getByText("PM Agent")).toBeDefined();
|
|
});
|
|
|
|
it("renders timestamp when provided", () => {
|
|
render(<ChatMessageIdentityBar agentName="Test" timestamp="2026-01-01T12:30:00Z" />);
|
|
// Should contain formatted time
|
|
const el = screen.getByText(/12:30/);
|
|
expect(el).toBeDefined();
|
|
});
|
|
|
|
it("applies role-specific color class", () => {
|
|
const { container } = render(
|
|
<ChatMessageIdentityBar agentName="PM" agentRole="pm" />
|
|
);
|
|
const nameEl = container.querySelector(".font-semibold");
|
|
expect(nameEl?.className).toContain("text-blue-600");
|
|
expect(nameEl?.className).toContain("dark:text-blue-400");
|
|
});
|
|
|
|
it("shows streaming indicator dot when isStreaming", () => {
|
|
const { container } = render(
|
|
<ChatMessageIdentityBar agentName="Test" isStreaming={true} />
|
|
);
|
|
const dot = container.querySelector(".animate-pulse");
|
|
expect(dot).toBeDefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
If `@testing-library/react` is not installed, use `createRoot` + `container.querySelector` pattern from `ChatInput.test.tsx`.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessageIdentityBar.tsx
|
|
- grep -q "agentRoleColors" ui/src/components/ChatMessageIdentityBar.tsx
|
|
- grep -q "ChatStreamingCursor" ui/src/components/ChatStreamingCursor.tsx
|
|
- grep -q "aria-hidden" ui/src/components/ChatStreamingCursor.tsx
|
|
- grep -q "animate-cursor-blink" ui/src/components/ChatStreamingCursor.tsx
|
|
- grep -q "agentName" ui/src/components/ChatMessage.tsx
|
|
- grep -q "agentRole" ui/src/components/ChatMessage.tsx
|
|
- grep -q "isStreaming" ui/src/components/ChatMessage.tsx
|
|
- grep -q "ChatMessageIdentityBar" ui/src/components/ChatMessage.tsx
|
|
- grep -q "ChatStreamingCursor" ui/src/components/ChatMessage.tsx
|
|
- grep -q "group" ui/src/components/ChatMessage.tsx
|
|
</acceptance_criteria>
|
|
<done>
|
|
- ChatMessageIdentityBar renders icon (h-4 w-4), agent name (13px semibold), timestamp (11px muted), and streaming dot
|
|
- Agent name and icon use role-specific Tailwind color classes from agentRoleColors
|
|
- ChatStreamingCursor is inline block with cursor-blink animation and aria-hidden
|
|
- ChatMessage accepts agentName, agentIcon, agentRole, timestamp, isStreaming props
|
|
- Assistant messages render identity bar when agentName is present
|
|
- Assistant messages show streaming cursor when isStreaming is true
|
|
- Tests pass for ChatMessageIdentityBar
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: ChatAgentSelector component</name>
|
|
<read_first>
|
|
- ui/src/api/agents.ts
|
|
- ui/src/components/AgentIconPicker.tsx
|
|
- ui/src/api/chat.ts
|
|
- ui/src/hooks/useChatConversations.ts
|
|
- ui/src/components/ChatAgentSelector.test.tsx
|
|
- .planning/phases/22-agent-streaming/22-UI-SPEC.md (lines 58-69 agent selector layout)
|
|
</read_first>
|
|
<files>
|
|
ui/src/components/ChatAgentSelector.tsx,
|
|
ui/src/components/ChatAgentSelector.test.tsx
|
|
</files>
|
|
<action>
|
|
**1. Create `ui/src/components/ChatAgentSelector.tsx`:**
|
|
|
|
```typescript
|
|
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";
|
|
import { useState } from "react";
|
|
|
|
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>
|
|
);
|
|
}
|
|
```
|
|
|
|
Per UI spec: trigger shows icon + name, max-w-120px, truncated; popover 200px wide; items show icon + name + role label; "Select agent" placeholder; "No agents configured" empty state. PATCH conversation on selection (optimistic update via onAgentChange callback).
|
|
|
|
**2. Replace test stubs in `ui/src/components/ChatAgentSelector.test.tsx`:**
|
|
Update with real tests. Since the component uses React Query and API calls, test the rendering logic:
|
|
```typescript
|
|
// @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");
|
|
it.todo("calls onAgentChange with new agentId on selection");
|
|
it.todo("shows 'No agents configured' when agent list is empty");
|
|
});
|
|
```
|
|
|
|
Keep most tests as todo since full integration tests require QueryClientProvider mocking. The export test confirms the component loads without errors.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- grep -q "ChatAgentSelector" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "aria-label" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "Active agent" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "Select agent" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "No agents configured" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "agentRoleColors" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "onAgentChange" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "updateConversation" ui/src/components/ChatAgentSelector.tsx
|
|
- grep -q "max-w-\[120px\]" ui/src/components/ChatAgentSelector.tsx
|
|
</acceptance_criteria>
|
|
<done>
|
|
- ChatAgentSelector renders active agent with icon + name + ChevronDown
|
|
- Trigger max-w-120px with truncation
|
|
- "Select agent" placeholder when no agent selected
|
|
- Popover lists agents with icon, name, and role label
|
|
- "No agents configured" empty state
|
|
- Selection calls onAgentChange and PATCHes conversation
|
|
- Role-specific colors applied to agent icons
|
|
- Loading state shows Skeleton
|
|
- TypeScript compiles clean
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `pnpm --filter @paperclipai/ui exec -- tsc --noEmit` passes
|
|
- `pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose` passes
|
|
- All new components export correctly
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Assistant messages show agent name, icon, and timestamp (AGENT-04)
|
|
- Agent icon colors are role-specific with dark: variants (THEME-03)
|
|
- Agent selector dropdown allows switching active agent per conversation (CHAT-08)
|
|
- Streaming cursor blinks during active generation
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md`
|
|
</output>
|