nexus/.planning/phases/22-agent-streaming/22-02-PLAN.md

18 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
22-agent-streaming 02 execute 1
22-00
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
true
AGENT-04
CHAT-08
THEME-03
truths artifacts key_links
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
path provides exports
ui/src/components/ChatAgentSelector.tsx Agent dropdown in ChatPanel header
ChatAgentSelector
path provides exports
ui/src/components/ChatMessageIdentityBar.tsx Agent icon + name + timestamp above assistant messages
ChatMessageIdentityBar
path provides exports
ui/src/components/ChatStreamingCursor.tsx Blinking inline cursor during streaming
ChatStreamingCursor
path provides exports
ui/src/components/ChatMessage.tsx Extended ChatMessage with identity bar, streaming cursor, hover actions
ChatMessage
from to via pattern
ui/src/components/ChatMessageIdentityBar.tsx ui/src/lib/agent-role-colors.ts import agentRoleColors agentRoleColors
from to via pattern
ui/src/components/ChatAgentSelector.tsx ui/src/api/agents.ts agentsApi.list agentsApi
from to via pattern
ui/src/components/ChatMessage.tsx ui/src/components/ChatMessageIdentityBar.tsx import ChatMessageIdentityBar ChatMessageIdentityBar
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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:

export const agentsApi = {
  list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
  // ...
};

From packages/shared/src/types/chat.ts:

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

export const agentRoleColors: Record<AgentRole, string>;
export const agentRoleColorDefault: string;

From ui/src/components/ChatMessage.tsx (current):

interface ChatMessageProps {
  role: "user" | "assistant" | "system";
  content: string;
}
export function ChatMessage({ role, content }: ChatMessageProps): JSX.Element;
Task 1: ChatMessageIdentityBar, ChatStreamingCursor, and extended ChatMessage - 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) ui/src/components/ChatMessageIdentityBar.tsx, ui/src/components/ChatStreamingCursor.tsx, ui/src/components/ChatMessage.tsx, ui/src/components/ChatMessageIdentityBar.test.tsx **1. Create `ui/src/components/ChatMessageIdentityBar.tsx`:**
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:

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:

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:

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:

// @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. pnpm --filter @paperclipai/ui vitest run src/components/ChatMessageIdentityBar.test.tsx --reporter=verbose <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> - 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

Task 2: ChatAgentSelector component - 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) ui/src/components/ChatAgentSelector.tsx, ui/src/components/ChatAgentSelector.test.tsx **1. Create `ui/src/components/ChatAgentSelector.tsx`:**
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:

// @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. pnpm --filter @paperclipai/ui exec -- tsc --noEmit 2>&1 | head -20 <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> - 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

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

<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>
After completion, create `.planning/phases/22-agent-streaming/22-02-SUMMARY.md`