21 KiB
| phase | slug | status | shadcn_initialized | preset | created | revised |
|---|---|---|---|---|---|---|
| 22 | agent-streaming | draft | true | new-york / neutral / cssVariables | 2026-04-01 | 2026-04-01 |
Phase 22 — UI Design Contract
Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.
Design System
| Property | Value |
|---|---|
| Tool | shadcn (new-york style) — carried forward from Phase 21 |
| Preset | new-york, baseColor: neutral, cssVariables: true |
| Component library | Radix UI (via shadcn) |
| Icon library | lucide-react (^0.574.0) |
| Font | System UI stack (inherited from existing CSS — no custom font declared) |
Source: ui/components.json, ui/src/index.css (unchanged from Phase 21)
shadcn components already installed (usable without install):
Avatar, Badge, Button, Card, Checkbox, Command, Dialog, DropdownMenu, Input, Popover, ScrollArea, Select, Separator, Sheet, Skeleton, Tabs, Textarea, Tooltip
New shadcn components for this phase (install before use):
None required — Select and DropdownMenu cover the AgentSelector. Avatar covers the agent badge avatar slot.
New npm packages for this phase (not shadcn registry):
virtua^0.49.0 — virtualized list for ChatMessageList (PERF-03). Install:pnpm --filter @paperclipai/ui add virtua- Server:
openaioraiSDK — LLM streaming. See RESEARCH.md for provider resolution. Not a UI registry concern.
Focal Point
The primary focal point of Phase 22 is the streaming message in progress — specifically the assistant message bubble that receives incoming tokens. While tokens arrive, the bubble is the only animated element on screen. The Stop button (rendered inside ChatInput in place of Send) is the secondary focal point during streaming.
Spacing Scale
Carried forward from Phase 21 without change. Source: 21-UI-SPEC.md.
| Token | Value | Usage |
|---|---|---|
| xs | 4px | Icon gaps (gap-1), inline icon + label spacing |
| sm | 8px | Compact padding inside list items, badge padding, button icon padding |
| sm+ | 12px | Conversation list item vertical padding (py-3) — named exception, follows EntityRow pattern |
| md | 16px | Default element padding (p-4), chat panel header padding |
| lg | 24px | Section padding on desktop (p-6) |
| xl | 32px | Gap between major UI zones |
| 2xl | 48px | Empty-state vertical padding (py-12) |
| 3xl | 64px | Page-level section breaks (not applicable in panel context) |
Phase 22 additions:
| Token | Value | Usage |
|---|---|---|
| agent-badge-gap | 8px (sm) | Gap between agent avatar and agent name in ChatAgentBadge |
| agent-avatar | 20px | Width and height of the agent avatar circle in ChatAgentBadge |
| streaming-dot | 8px (sm) | Width and height of the pulsing streaming cursor dot |
Exceptions (unchanged from Phase 21):
sm+(12px): conversation list item vertical padding only.- Touch targets:
min-height: 44pxon coarse-pointer devices (globalindex.cssrule). - Chat input bottom padding:
pb-[calc(env(safe-area-inset-bottom)+16px)]on mobile.
Typography
Carried forward from Phase 21 without change. Source: 21-UI-SPEC.md.
Two weights only: regular (400) and semibold (600).
| Role | Size | Weight | Line Height | Tailwind Class |
|---|---|---|---|---|
| Body | 14px | 400 (regular) | 1.5 | text-sm |
| Label | 13px | 400 (regular) | 1.4 | text-[13px] |
| Heading | 16px | 600 (semibold) | 1.25 | text-base font-semibold |
| Meta / Timestamp | 12px | 400 (regular) | 1.4 | text-xs text-muted-foreground |
Phase 22 typography additions:
- Agent name in
ChatAgentBadge: Label (13px / regular /text-muted-foreground). Not semibold — the name is contextual metadata, not a heading. - Slash command suggestion label in the command popover: Body (14px / regular).
- Agent selector trigger label: Label (13px / regular), truncated with
truncate. - Stop button label (text inside ChatInput during streaming): Body (14px / regular), no special weight. The Square icon carries the visual signal.
Rules (unchanged):
- Agent message content inside
ChatMarkdownMessageusesprose prose-sm— do not override. - The chat input
Textareauses Body (14px / regular). - Timestamps use Meta (12px / regular / muted-foreground), visible on hover only.
Color
All colors reference CSS custom properties already declared for all three themes in ui/src/index.css. Never hard-code hex values. Source: 21-UI-SPEC.md + RESEARCH.md Pattern 6.
| Role | CSS Variable | 60/30/10 | Usage |
|---|---|---|---|
| Dominant surface | --background |
60% | Chat panel background, message list background |
| Secondary surface | --card / --sidebar |
30% | Conversation list sidebar, code block container |
| Accent | --primary |
10% | Send button, active conversation border-left, filled pin icon |
| Muted | --muted |
— | Input background, empty state icon container, streaming cursor background |
| Muted foreground | --muted-foreground |
— | Placeholder text, timestamps, agent name in badge, secondary labels |
| Destructive | --destructive |
— | Delete conversation action only (carried from Phase 21) |
| Border | --border |
— | Dividers, chat panel border, input border |
Accent (--primary) reserved for exactly these elements (Phase 22 carries Phase 21 list, no additions):
- The "Send message" primary action button background
- The active conversation in the sidebar (left-border indicator)
- Filled pin icon on pinned conversations
Phase 22 agent colors — CSS custom properties (THEME-03):
Agent role colors use --chart-1 through --chart-5 CSS custom properties already declared for all three themes in ui/src/index.css. These are the ONLY variables permitted for agent identity coloring. Implementation via bg-[hsl(var(--chart-N))] Tailwind utility.
| Agent Role | CSS Variable | Tailwind Class |
|---|---|---|
ceo |
--chart-1 |
bg-[hsl(var(--chart-1))] |
pm |
--chart-2 |
bg-[hsl(var(--chart-2))] |
engineer |
--chart-3 |
bg-[hsl(var(--chart-3))] |
general / generalist |
--chart-4 |
bg-[hsl(var(--chart-4))] |
brainstormer |
--chart-5 |
bg-[hsl(var(--chart-5))] |
| Unknown / fallback | --muted |
bg-muted |
Text on all agent color backgrounds: text-white (sufficient contrast on all three themes per chart variable definitions). Do not use text-foreground — the chart variables are saturated colors and require white text.
Stop button color: The Stop button uses variant="destructive" from shadcn Button — it uses --destructive background with destructive-foreground text. This signals urgency (cancel an ongoing action) without being confused with a data-destructive action.
Component Inventory
Modified from Phase 21
ChatInput (modified)
- Existing behavior fully preserved (auto-resize, Enter/Shift+Enter, Escape)
- Addition: Stop button during streaming
- When
streaming === true: Send button is replaced by a Stop button - Stop button:
variant="destructive", icon-only (Squarefrom lucide, 16px),aria-label="Stop generation" - Stop button is NEVER disabled — it must always be clickable during streaming
- Layout: same position as Send button (flex row right of Textarea). Dimensions identical to Send button so the layout does not shift when toggling.
- When
- Addition: Slash command popover
- Trigger: when input value starts with
/and matches a known prefix - Popover appears above the input (
<Popover>withside="top" align="start") - Contents: list of matching commands using
<Command>component (already installed) - Command item format:
/{command}label (14px / regular) + destination agent name (13px / muted-foreground) - Dismiss: Escape, backspace past
/, or clicking away - Max 5 items displayed; no scrolling inside the popover
- Commands list (hardcoded):
- Trigger: when input value starts with
| Command | Label | Agent Destination |
|---|---|---|
/brainstorm |
Brainstorm an idea | Brainstormer |
/ask-pm |
Ask the PM | PM |
/ask-engineer |
Ask the Engineer | Engineer |
/task |
Create a task | Engineer |
/search |
Search conversations | Generalist |
- Addition: @mention popover
- Trigger: when input value starts with
@and has at least 1 character following - Same
<Popover>+<Command>pattern as slash commands - Contents: filtered list of agent names from
useAgents()cache - Item format:
AgentBadge(20px avatar) + agent name (14px / regular) - Dismiss: same as slash command popover
- Trigger: when input value starts with
- No change to aria-label, placeholder, or keyboard shortcuts
ChatMessageList (modified)
- Replace flat div with
<VList>fromvirtua(PERF-03)- Drop
<ScrollArea>wrapper —virtuamanages scroll internally <VList style={{ flex: 1 }} ref={listRef}>wraps all message items- Auto-scroll to bottom logic:
isAtBottomstate derived from VListonScrollcallback — only auto-scroll whenisAtBottom === true - Threshold:
isAtBottom = scrollOffset + clientHeight >= scrollSize - 80(80px tolerance)
- Drop
- Addition: streaming optimistic message
- When
streaming === true: append a syntheticChatMessageItemat bottom with:role: "assistant"content: partialContent(fromuseStreamMessagehook)isStreaming: trueprop
- Streaming indicator: a pulsing dot (
w-2 h-2 rounded-full bg-muted animate-pulse) appended after the last rendered markdown token - The streaming item has no timestamp (omit timestamp row entirely while
isStreaming) - The streaming item has no action buttons (edit/retry are not shown on in-progress messages)
- When
- Addition: message action buttons (edit/retry) on existing assistant messages
- Visible on hover only (
opacity-0 group-hover:opacity-100 transition-opacity duration-150) - Position: below the message bubble, right-aligned (
flex justify-end gap-1 mt-1) - Retry button:
variant="ghost" size="icon-sm", iconRotateCcw(14px),aria-label="Retry response" - Edit button: on user messages only —
variant="ghost" size="icon-sm", iconPencil(14px),aria-label="Edit message" - While streaming is in progress (
streaming === true): hide all action buttons on all messages (prevent conflicting actions)
- Visible on hover only (
- Addition:
ChatAgentBadgeabove each assistant message bubble- See new component spec below
ChatPanel (modified)
- Addition:
AgentSelectorin the panel header- Position: right side of the header bar (flex row,
ml-autobefore close button) - See new component spec below
- Position: right side of the header bar (flex row,
- Header height remains 48px — AgentSelector must fit within this height
- No other structural changes
New Components for Phase 22
ChatAgentBadge
- Rendered above each assistant message bubble in
ChatMessageList - Layout:
flex items-center gap-2 mb-1(8px gap —smtoken; 4px margin-bottom) - Avatar: 20px circle (
w-5 h-5 rounded-full flex items-center justify-center), background color fromagentRoleColorClass(agent.role)(see Color section)- If agent has an
iconvalue: renderAgentIconat 12px size inside the circle - If no icon: render the first letter of
agent.nameattext-[10px] font-semibold text-white
- If agent has an
- Agent name: Label (13px / regular /
text-muted-foreground), truncated at 120px max-width - Source:
agent.nameandagent.roleresolved client-side fromuseAgents(companyId)cache keyed byagentIdon the message - Fallback when agent not found in cache: show
Boticon (lucide, 12px) + "Agent" as name,bg-mutedbackground — never throw or show nothing
AgentSelector
- Location: ChatPanel header, right side before the close button
- Component:
<Select>(shadcn) with a custom trigger styled to match the 48px header height - Trigger: compact,
h-8 px-2 py-1, shows current agent avatar (16px, same color circle pattern as ChatAgentBadge) + agent name Label (13px / regular) - Dropdown items: each agent as a
<SelectItem>, showingChatAgentBadge-style avatar (16px) + agent name (14px / regular) in a flex row - Current agent persisted to
conversation.agentIdviaPATCH /api/conversations/:id— this is the per-conversation default agent - Empty state: if no agents available, show a single disabled item "No agents configured"
- Loading state:
<Skeleton className="h-8 w-28" />while agents are fetching - Tooltip on the selector trigger: "Active agent for this conversation" (shown on hover via
<Tooltip>)
Interaction Contract
Streaming lifecycle (CHAT-01, CHAT-12)
| State | ChatInput | ChatMessageList |
|---|---|---|
| Idle | Send button enabled; textarea enabled | No streaming indicator |
| Sending user message | Send button disabled, Loader2 spin icon, textarea disabled |
User message appears immediately (optimistic insert) |
| Streaming in progress | Stop button (variant="destructive", Square icon); textarea disabled |
Streaming assistant message at bottom with pulsing dot; action buttons hidden on all messages |
| Stop clicked | Stop button transitions briefly to Loader2 for 200ms, then reverts to Send |
Streaming message remains in list with last received content (partial); pulsing dot removed; action buttons re-appear |
| Stream complete | Reverts to Send button; textarea re-enabled and focused | Streaming indicator removed; persisted message replaces optimistic; RotateCcw + other action buttons appear on hover |
| Stream error | Reverts to Send button; textarea re-enabled; error toast fires | Streaming message removed from list |
Message editing inline (CHAT-10)
- Trigger: click
Pencilicon on hover over any user message - The user message bubble text is replaced with a
<Textarea>pre-filled with the original content - Min height: 40px; max height: 120px;
variant: none(no extra border — let the bubble container provide the boundary) - Confirm: Enter (without Shift), or a "Regenerate" button (
variant="default", 14px label "Regenerate") that appears below the edit area - Cancel: Escape — restores original content, hides textarea
- On submit: POST the edited content to
PUT /api/messages/:id; this re-triggers the stream from that point; messages after the edited message are NOT removed from the UI in Phase 22 (branching is Phase 24 scope) - While editing: all other action buttons in the list are hidden
Response regeneration (CHAT-11)
- Trigger: click
RotateCcwon hover over any existing assistant message - Confirmation: none — immediate action
- Behavior: re-invokes the stream route for that message position; a new streaming message appears below the existing one; the existing assistant message is retained in the list until the new stream completes, at which point the existing message is replaced with the new one in-place
- While regenerating: Stop button active in ChatInput; all other action buttons hidden
Agent selector interaction (CHAT-08)
- Trigger: open
AgentSelectordropdown in ChatPanel header - Selection: clicking an agent item immediately calls
PATCH /api/conversations/:idwith{ agentId }and updates local state optimistically - Error: if PATCH fails, revert to previous agent selection and show a toast: "Couldn't update agent. Try again."
- The selected agent takes effect on the next message sent — it does NOT retroactively re-label past messages
Slash command and @mention routing (INPUT-05, INPUT-06)
- Slash command: when ChatInput contains
/ask-engineer Hello, the command prefix is parsed before send; the message body "Hello" is sent withtargetRole: "engineer"as a per-message agent override; the conversation'sagentIdis NOT changed - @mention: same per-message override behavior;
@engineer HelloextractstargetName: "engineer"and resolves to the matching agent by name (case-insensitive) - If a command is recognized but the target agent does not exist in
useAgents()cache: show inline toast "No engineer agent found. Message sent to active agent." and fall back toconversation.agentId - If the
/prefix does not match any known command: send as plain text (no routing, no error) - Popover keyboard navigation: arrow keys move selection, Enter selects, Tab accepts, Escape dismisses
Auto-scroll behavior during streaming (PERF-03)
- When
isAtBottom === trueat the time a new token arrives: calllistRef.current?.scrollToIndex(allMessages.length - 1, { smooth: false })to keep bottom in view - When user has scrolled up (isAtBottom === false): do NOT scroll; show a "Jump to bottom" button:
<Button variant="outline" size="sm">with aChevronDownicon (16px), positionedabsolute bottom-20 right-4 z-10relative to the message list container. Clicking it scrolls to bottom and setsisAtBottom = true - "Jump to bottom" button disappears immediately when
isAtBottom === true
Copywriting Contract
Source: Claude's discretion (discuss phase skipped). Follows Phase 21 tone: direct, lowercase preference, no corporate language.
| Element | Copy |
|---|---|
| Stop button tooltip | "Stop generation" |
| Retry button tooltip | "Retry response" |
| Edit button tooltip (on user message) | "Edit message" |
| Edit confirm button | "Regenerate" |
| Edit cancel instruction | Press Escape to cancel |
| Streaming indicator aria-label | "Response streaming" |
| Agent selector tooltip | "Active agent for this conversation" |
| Agent selector empty | "No agents configured" |
| Jump to bottom button aria-label | "Jump to bottom" |
| Slash command popover heading | (none — no heading; list items self-describe) |
| @mention popover heading | (none — no heading; agent names self-describe) |
| Error: stream failed | "Response failed. Try again." |
| Error: edit failed | "Couldn't update message. Try again." |
| Error: agent update failed | "Couldn't update agent. Try again." |
| Error: target agent not found | "No {role} agent found. Message sent to active agent." |
| Error: retry failed | "Retry failed. Try again." |
| Toast on stop (if partial message not saved) | (no toast — stopping is expected; silent is correct) |
No new destructive actions in Phase 22. The Stop generation action is a cancellation, not a destructive action — no confirmation required.
Accessibility Contract
Carried forward from Phase 21. Phase 22 additions:
ChatAgentBadgeavatar:aria-hidden="true"(decorative — the agent name provides the text label)- Agent name in
ChatAgentBadge:aria-label="Agent: {agent.name}"on the containing span AgentSelectortrigger:aria-label="Active agent: {currentAgent.name}"(dynamically updated)- Stop button:
aria-label="Stop generation", neveraria-disabled— always interactive during streaming - Streaming message container:
aria-live="off"— do NOT usearia-live="polite"on the partial content container; the completed persisted message in therole="log"container is sufficient for screen reader announcement. Streaming token-by-token announcement would be noisy. - Edit textarea:
aria-label="Edit message",aria-multiline="true" - "Jump to bottom" button:
aria-label="Jump to bottom",aria-hidden="true"when not visible - Slash command popover:
role="listbox", each itemrole="option",aria-selectedon highlighted item - @mention popover: same
role="listbox"pattern
Animation Contract
All animations use Tailwind utilities or existing CSS transitions. No new animation libraries.
Carried from Phase 21:
| Element | Animation | Duration | Easing |
|---|---|---|---|
| Chat panel open/close | transition-[width] |
100ms | ease-out |
| Conversation list item hover | transition-colors |
150ms | Tailwind default |
Phase 22 additions:
| Element | Animation | Duration | Easing |
|---|---|---|---|
| Streaming cursor dot | animate-pulse (Tailwind) |
continuous | built-in |
| Message action buttons (edit/retry/stop) | transition-opacity |
150ms | ease-out |
| Send → Stop button swap | transition-opacity |
100ms | ease-out (cross-fade; layout does NOT shift) |
| "Jump to bottom" button appear/disappear | transition-opacity |
150ms | ease-out |
| AgentSelector dropdown open | Radix <Select> built-in animation |
150ms | Radix default |
| Slash/mention popover open | transition-opacity duration-100 |
100ms | ease-out |
No enter/exit animations for new streaming tokens — tokens append directly with no animation. This keeps PERF-02 (100ms latency) achievable without layout thrashing.
Registry Safety
| Registry | Blocks Used | Safety Gate |
|---|---|---|
| shadcn official | All components from Phase 21 + Command, Popover, Select (already installed) |
not required |
| Third-party registries | none | not applicable |
virtua is installed via npm (not shadcn registry). npm packages are not subject to the shadcn registry safety gate.
No third-party shadcn registry blocks are declared for this phase.
Checker Sign-Off
- Dimension 1 Copywriting: PASS
- Dimension 2 Visuals: PASS
- Dimension 3 Color: PASS
- Dimension 4 Typography: PASS
- Dimension 5 Spacing: PASS
- Dimension 6 Registry Safety: PASS
Approval: pending