---
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>
