nexus/.planning/phases/22-agent-streaming/22-UI-SPEC.md
Mikkel Georgsen 8e1ce40a4a [nexus] docs(22): UI design contract for agent-streaming phase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:23:40 +02:00

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: openai or ai SDK — 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: 44px on coarse-pointer devices (global index.css rule).
  • 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 ChatMarkdownMessage uses prose prose-sm — do not override.
  • The chat input Textarea uses 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):

  1. The "Send message" primary action button background
  2. The active conversation in the sidebar (left-border indicator)
  3. 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 (Square from 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.
  • Addition: Slash command popover
    • Trigger: when input value starts with / and matches a known prefix
    • Popover appears above the input (<Popover> with side="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):
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
  • No change to aria-label, placeholder, or keyboard shortcuts

ChatMessageList (modified)

  • Replace flat div with <VList> from virtua (PERF-03)
    • Drop <ScrollArea> wrapper — virtua manages scroll internally
    • <VList style={{ flex: 1 }} ref={listRef}> wraps all message items
    • Auto-scroll to bottom logic: isAtBottom state derived from VList onScroll callback — only auto-scroll when isAtBottom === true
    • Threshold: isAtBottom = scrollOffset + clientHeight >= scrollSize - 80 (80px tolerance)
  • Addition: streaming optimistic message
    • When streaming === true: append a synthetic ChatMessageItem at bottom with:
      • role: "assistant"
      • content: partialContent (from useStreamMessage hook)
      • isStreaming: true prop
    • 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)
  • 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", icon RotateCcw (14px), aria-label="Retry response"
    • Edit button: on user messages only — variant="ghost" size="icon-sm", icon Pencil (14px), aria-label="Edit message"
    • While streaming is in progress (streaming === true): hide all action buttons on all messages (prevent conflicting actions)
  • Addition: ChatAgentBadge above each assistant message bubble
    • See new component spec below

ChatPanel (modified)

  • Addition: AgentSelector in the panel header
    • Position: right side of the header bar (flex row, ml-auto before close button)
    • See new component spec below
  • 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 — sm token; 4px margin-bottom)
  • Avatar: 20px circle (w-5 h-5 rounded-full flex items-center justify-center), background color from agentRoleColorClass(agent.role) (see Color section)
    • If agent has an icon value: render AgentIcon at 12px size inside the circle
    • If no icon: render the first letter of agent.name at text-[10px] font-semibold text-white
  • Agent name: Label (13px / regular / text-muted-foreground), truncated at 120px max-width
  • Source: agent.name and agent.role resolved client-side from useAgents(companyId) cache keyed by agentId on the message
  • Fallback when agent not found in cache: show Bot icon (lucide, 12px) + "Agent" as name, bg-muted background — 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>, showing ChatAgentBadge-style avatar (16px) + agent name (14px / regular) in a flex row
  • Current agent persisted to conversation.agentId via PATCH /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 Pencil icon 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 RotateCcw on 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 AgentSelector dropdown in ChatPanel header
  • Selection: clicking an agent item immediately calls PATCH /api/conversations/:id with { 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 with targetRole: "engineer" as a per-message agent override; the conversation's agentId is NOT changed
  • @mention: same per-message override behavior; @engineer Hello extracts targetName: "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 to conversation.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 === true at the time a new token arrives: call listRef.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 a ChevronDown icon (16px), positioned absolute bottom-20 right-4 z-10 relative to the message list container. Clicking it scrolls to bottom and sets isAtBottom = 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:

  • ChatAgentBadge avatar: aria-hidden="true" (decorative — the agent name provides the text label)
  • Agent name in ChatAgentBadge: aria-label="Agent: {agent.name}" on the containing span
  • AgentSelector trigger: aria-label="Active agent: {currentAgent.name}" (dynamically updated)
  • Stop button: aria-label="Stop generation", never aria-disabled — always interactive during streaming
  • Streaming message container: aria-live="off" — do NOT use aria-live="polite" on the partial content container; the completed persisted message in the role="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 item role="option", aria-selected on 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