13 files reviewed with mission-control design lens. Key additions: - theme: extended indigo scale, 4-level surface hierarchy, 22 terminal tokens, transition/z-index/focus-visible token categories - All screens: keyboard shortcuts, loading/error/empty states hardened - 5 new shared components: StatusDot, SkeletonLoader, Toast, Badge, KeyboardShortcutHint - settings: expanded from 2 to 5 sub-pages (accounts, workspace, danger zone) - review-tab: 3-pane layout, inline comments, file nav, hunk controls - execution-tab: zoom, partial failure state, stale agent detection - dialogs: 2 bugs found (mutation locking, error placement) Total: 4,039 → 9,302 lines (+130% from review pass)
47 KiB
Shared Components (v2)
NEW — no v1 equivalent
Reusable component specifications used across multiple pages. Each component includes: description, props/API, ASCII mockups of all states, usage locations, and proposed source file path.
1. <SaveIndicator>
Inline status indicator for auto-save operations. Shows current save state with icon + label, auto-hides on success.
Props
interface SaveIndicatorProps {
status: 'idle' | 'saving' | 'saved' | 'error';
onRetry?: () => void;
/**
* When true, "saved" state displays a persistent timestamp ("Saved at 3:42 PM")
* instead of fading out after 2s. Use for high-stakes contexts like page content
* editing where the user needs ongoing confirmation that their work is safe.
* Default: false (fade-out behavior).
*/
persistent?: boolean;
}
States
Idle: (hidden — renders nothing)
Saving: [spinner] Saving...
Saved: [v] Saved <- fades out after 2s, transitions to idle
Error: [!] Failed to save [retry]
Detailed ASCII
Saving
+---------------------------+
| [spinner] Saving... |
+---------------------------+
text-muted-foreground text-sm- Spinner is a 14px animated SVG
Saved (default: persistent=false)
+---------------------------+
| [v] Saved |
+---------------------------+
text-green-600 text-sm- Checkmark icon, 14px
- Fades to
opacity-0over 300ms after 2s delay, then sets status toidle
Saved (persistent mode: persistent=true)
+---------------------------+
| [v] Saved at 3:42 PM |
+---------------------------+
- Same styling as default saved state, but does NOT fade out
- Timestamp updates on each save, formatted via
Intl.DateTimeFormatwithhour/minute - Remains visible until next
savingorerrortransition
Error
+--------------------------------------+
| [!] Failed to save [retry] |
+--------------------------------------+
text-destructive text-sm- Alert icon, 14px
[retry]is a text button:text-destructive underline cursor-pointer- Does not auto-hide — persists until retry succeeds or user navigates away
Usage
| Page | Location | Persistent? |
|---|---|---|
| Content tab | Top-right of Tiptap editor toolbar (see content-tab.md) | Yes — page content is high-stakes |
| Plan tab | Top-right of phase description editor (see plan-tab.md) | No — phase descriptions are lightweight |
Source
packages/web/src/components/SaveIndicator.tsx(proposed)
2. <EmptyState>
Centered placeholder shown when a list or section has no items. Configurable icon, messaging, and optional CTA button.
Props
interface EmptyStateProps {
icon: React.ReactNode; // e.g., <Inbox />, <ListTodo />, <Users />
title: string; // e.g., "No initiatives yet"
description?: string; // e.g., "Create an initiative to get started."
action?: {
label: string; // e.g., "Plan with Agent"
onClick: () => void;
icon?: React.ReactNode; // e.g., <Sparkles /> — rendered before label
};
secondaryAction?: {
label: string; // e.g., "Add Phase"
onClick: () => void;
icon?: React.ReactNode; // e.g., <Plus />
};
}
When both action and secondaryAction are provided, the primary action
renders as <Button variant="default"> and the secondary as
<Button variant="outline">, side by side with gap-3.
Default (with action)
+-------------------------------------------+
| |
| [icon] |
| No phases yet |
| Add a phase to start planning. |
| |
| [ Add Phase ] |
| |
+-------------------------------------------+
Without action
+-------------------------------------------+
| |
| [icon] |
| No conversations yet |
| Questions from agents appear here. |
| |
+-------------------------------------------+
With primary + secondary action
+-------------------------------------------+
| |
| [icon] |
| No phases yet |
| Add a phase to start planning, |
| or let an agent plan for you. |
| |
| [sparkles Plan with Agent] [+ Add Phase]|
| |
+-------------------------------------------+
- Primary:
<Button variant="default">(filled indigo) - Secondary:
<Button variant="outline">(border only) - Container:
flex gap-3 justify-center
Without description
+-------------------------------------------+
| |
| [icon] |
| No agents running |
| |
+-------------------------------------------+
Styling
- Container:
flex flex-col items-center justify-center py-16 text-center - Icon:
h-12 w-12 text-muted-foreground mb-4 - Title:
text-lg font-medium text-foreground mb-1 - Description:
text-sm text-muted-foreground mb-6 - Action button: default shadcn
<Button>variant
Usage
| Page | Title | Primary Action | Secondary Action |
|---|---|---|---|
| Initiatives list (no data) | "No initiatives yet" | "New Initiative" | -- |
| Initiatives list (no match) | "No matching initiatives" | -- (text link instead) | -- |
| Plan tab (no phases) | "No phases yet" | "Plan with Agent" | "Add Phase" |
| Execution tab (no tasks) | "No tasks to execute" | "Go to Plan Tab" | -- |
| Agents page (no agents) | "No agents running" | -- | -- |
| Inbox page (no conversations) | "No conversations yet" | -- | -- |
| Settings > Accounts (none) | "No accounts registered" | "Add Account" | -- |
Source
packages/web/src/components/EmptyState.tsx(proposed)
3. <ErrorState>
Centered error display with optional retry action. Used when a data fetch fails or a section encounters an unrecoverable error.
Props
interface ErrorStateProps {
message: string; // e.g., "Failed to load initiatives"
onRetry?: () => void; // If provided, shows Retry button
details?: string; // Technical error detail (stack trace, API response)
errorCode?: string; // e.g., "TRPC_TIMEOUT", "NETWORK_ERROR"
}
With retry
+-------------------------------------------+
| |
| [AlertCircle] |
| Failed to load initiatives |
| |
| [ Retry ] |
| |
+-------------------------------------------+
Without retry (navigation fallback)
+-------------------------------------------+
| |
| [AlertCircle] |
| Error loading initiative |
| Could not fetch initiative data. |
| |
| [ Back to Initiatives ] |
| |
+-------------------------------------------+
With details + error code (expandable)
+-------------------------------------------+
| |
| [AlertCircle] |
| Failed to load initiatives |
| Error: TRPC_TIMEOUT |
| |
| [ Retry ] |
| |
| [v] Show details |
| +-----------------------------------+|
| | TRPCClientError: Request timed ||
| | out after 10000ms ||
| | at fetchQuery (trpc-client.ts: ||
| | 42:11) ||
| +-----------------------------------+|
| |
+-------------------------------------------+
Error: <code>—text-sm text-muted-foreground font-mono, shown below the message whenerrorCodeis provided[v] Show details— collapsible trigger,text-xs text-muted-foreground cursor-pointer- Details panel:
bg-muted rounded-md p-3 font-mono text-xs text-muted-foreground max-h-32 overflow-y-auto - Details are collapsed by default — click to expand. Uses a
<details>/<summary>element or state toggle. - When
detailsprop is absent, the collapsible section is not rendered at all.
Note: The "without retry" variant is not a prop configuration of <ErrorState>
itself. Pages that need navigation instead of retry render <ErrorState>
without onRetry and add their own navigation button below it.
Styling
- Container:
flex flex-col items-center justify-center py-16 text-center - Icon:
h-12 w-12 text-destructive mb-4(AlertCircle from lucide-react) - Message:
text-lg font-medium text-foreground mb-4 - Retry button:
<Button variant="outline">Retry</Button>
Usage
| Page | Message | Has Retry? |
|---|---|---|
| Initiatives list | "Failed to load initiatives" | Yes |
| Initiative detail | "Error loading initiative" | No (nav button) |
| Review tab | "Failed to load proposals" | Yes |
| Agents page | "Failed to load agents" | Yes |
| Settings page | "Failed to load settings" | Yes |
Source
packages/web/src/components/ErrorState.tsx(proposed)
4. <CommandPalette>
Global search overlay triggered by keyboard shortcut. Provides quick navigation to initiatives, agents, tasks, and settings pages.
Trigger
Cmd+K(Mac) /Ctrl+K(Windows)- Header button
[cmd-k](see app-layout.md)
Props
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
Internally fetches data via tRPC queries (initiatives, agents, tasks).
Default State (open, empty query)
+----------------------------------------------------------+
| [search] Search everything... [Esc] |
| --------------------------------------------------------|
| Recent |
| -> Auth System Overhaul |
| -> Agents |
| -> Settings |
+----------------------------------------------------------+
Shows recent navigation history when search is empty.
With Search Results
+----------------------------------------------------------+
| [search] auth___ [Esc] |
| --------------------------------------------------------|
| Initiatives |
| Auth System Overhaul [ACTIVE] |
| --------------------------------------------------------|
| Tasks |
| Implement auth middleware [execute] 3/7 |
| Review auth token flow [verify] done |
| --------------------------------------------------------|
| Agents |
| blue-fox-7 (auth middleware) [RUNNING] |
+----------------------------------------------------------+
No Results
+----------------------------------------------------------+
| [search] xyznonexistent___ [Esc] |
| --------------------------------------------------------|
| |
| No results for "xyznonexistent" |
| |
| Try a shorter query, or browse: |
| -> All Initiatives |
| -> All Agents |
| -> Settings |
| |
+----------------------------------------------------------+
- "Try a shorter query, or browse:" —
text-sm text-muted-foreground - Fallback navigation links are always the same three static items
- Clicking a fallback link navigates and closes the palette
Keyboard Navigation
[search] auth___ [Esc]
---------------------------------------------------------
Initiatives
> Auth System Overhaul [ACTIVE] <- highlighted
---------------------------------------------------------
Tasks
Implement auth middleware [execute] 3/7
>indicates the currently highlighted itemArrow Up/Arrow Down— move highlightEnter— navigate to highlighted item, close paletteEsc— close palette- Type to filter — results update as you type (debounced 100ms)
Result Groups
| Group | Source | Display |
|---|---|---|
| Initiatives | listInitiatives query |
Name + status badge |
| Tasks | listTasks query (across initiatives) |
Name + category + status |
| Agents | listAgents query |
Name + task description + status badge |
| Pages | Go to Initiatives, Go to Agents, etc. |
Static navigation items |
Groups are hidden when they have 0 matches. Max 5 items per group. Maximum total visible results: 20 (across all groups combined).
Recent Items
Recent navigation history is stored in sessionStorage key cw-cmd-recent
as an ordered array of { type, id, label, path } objects (max 10 entries,
LRU eviction). Navigating to any entity via the palette pushes it to the
front. The "Recent" section only appears when the search query is empty.
Recent [clock icon per row]
-> Auth System Overhaul [initiative]
-> blue-fox-7 [agent]
-> Settings [page]
Fuzzy Matching Behavior
- Uses substring match by default (case-insensitive)
- Query is split on whitespace:
"auth pkce"matches "Implement auth PKCE flow" - Each token must appear somewhere in the candidate string (AND logic)
- No typo tolerance — keep it simple. If fuzzy matching via a library like
fuse.jsis added later, configure threshold conservatively (0.3) to avoid noise in a mission-control context where precision matters.
Result Ranking
Within each group, results are ordered by:
- Exact prefix match — query matches the start of the name (highest)
- Substring position — earlier match index ranks higher
- Recency — more recently updated items break ties (
updatedAtDESC)
Section Keyboard Shortcuts
| Key | Action |
|---|---|
Arrow Up / Arrow Down |
Move highlight between items (wraps around) |
Enter |
Navigate to highlighted item, close palette |
Esc |
Close palette |
Backspace on empty query |
Close palette |
Cmd+K / Ctrl+K while open |
Close palette (toggle behavior) |
Overlay Behavior
- Renders as a centered modal with backdrop (
bg-black/50) - Width:
max-w-lg(512px) - Position:
top-[20%]of viewport - Backdrop click closes the palette
- Focus is trapped inside the modal while open
- Search input auto-focuses on open
Source
packages/web/src/components/CommandPalette.tsx(proposed)
5. <ThemeToggle>
3-state segmented control for switching between light, system, and dark
color schemes. Persists selection to localStorage.
Props
// No props — reads/writes theme from ThemeProvider context
// ThemeProvider wraps the app root
type Theme = 'light' | 'system' | 'dark';
States
Light active: [*sun*] [monitor] [moon]
System active: [sun] [*monitor*] [moon]
Dark active: [sun] [monitor] [*moon*]
Detailed ASCII
Light mode selected
+-----+----------+---------+
| sun | monitor | moon |
+-----+----------+---------+
^^^
filled bg, active text
System mode selected (default)
+-----+----------+---------+
| sun | monitor | moon |
+-----+----------+---------+
^^^^^^^^
filled bg, active text
Dark mode selected
+-----+----------+---------+
| sun | monitor | moon |
+-----+----------+---------+
^^^^^^
filled bg, active text
Styling
- Container:
inline-flex rounded-lg bg-muted p-0.5 gap-0.5 - Segment (inactive):
px-2 py-1 rounded-md text-muted-foreground hover:text-foreground - Segment (active):
px-2 py-1 rounded-md bg-background text-foreground shadow-sm - Icons: 16px lucide-react icons (
Sun,Monitor,Moon) - No text labels — icons only in the header (compact)
Tooltip on hover
[sun] -> "Light"
[monitor] -> "System (Dark)" or "System (Light)" — reflects resolved preference
[moon] -> "Dark"
The System tooltip dynamically resolves the current prefers-color-scheme
media query to show the effective mode. This eliminates user confusion about
what "System" actually means on their machine. Read isDark from the
ThemeProvider context to determine the parenthetical label.
Persistence
- Stored in
localStoragekeycw-theme - Default:
system - On load: reads
localStorage, appliesclass="dark"to<html>if theme isdarkor if theme issystemandprefers-color-scheme: dark
Usage
| Page | Location |
|---|---|
| App layout (header) | Right cluster, between Cmd+K button and health dot |
See app-layout.md for header placement.
Source
packages/web/src/components/ThemeToggle.tsx(proposed)packages/web/src/providers/ThemeProvider.tsx(proposed)
6. <StatusDot>
Semantic status indicator rendered as a small colored circle. Used pervasively
across the UI for agents, tasks, health, conversations, and previews. This is
the single most repeated visual element in the app — it needs to be a proper
component, not ad-hoc <span className="h-2 w-2 rounded-full bg-..." /> copy-pasted everywhere.
Props
interface StatusDotProps {
/**
* Maps to a status token from the theme (status-active-dot, status-success-dot, etc.).
* This is the semantic status, not a color — the theme resolves the actual hue.
*/
status: 'active' | 'success' | 'warning' | 'error' | 'neutral' | 'urgent';
/**
* Dot diameter. Defaults to 'md'.
* - 'sm' (6px): inline with text, badges, compact lists
* - 'md' (8px): agent cards, task rows, sidebar items
* - 'lg' (10px): health indicator in header, large card headers
*/
size?: 'sm' | 'md' | 'lg';
/**
* When true, applies a pulsing opacity animation (0.4 -> 1.0, 2s cycle).
* Use for statuses that represent ongoing activity (running agent, building preview).
* Default: false.
*
* Animation uses opacity only — no scale — to avoid layout shifts in tight layouts
* like agent cards. The keyframes:
* 0% { opacity: 1 }
* 50% { opacity: 0.4 }
* 100% { opacity: 1 }
*/
pulse?: boolean;
/**
* Accessible label for screen readers. Required.
* Examples: "Running", "Crashed", "Connected", "Blocked"
*
* Rendered as `aria-label` on the element and `role="status"`.
* Status should never be communicated through color alone —
* this label is the accessible equivalent.
*/
label: string;
}
Size Variants
sm (6px): * <- inline with text runs
md (8px): (*) <- default, agent cards
lg (10px): ( * ) <- header health dot
Visual Rendering
+--------------------------------------------+
| Active (pulse): (*) ← opacity animation |
| Success (static): (v) ← solid green |
| Warning (static): (?) ← solid amber |
| Error (static): (!) ← solid red |
| Neutral (static): (-) ← solid grey |
| Urgent (pulse): (!) ← pulsing purple |
+--------------------------------------------+
Color Mapping (from theme tokens)
| Status | Token | Typical Color | Default Pulse? |
|---|---|---|---|
active |
status-active-dot |
Blue | Yes (running agents, building previews) |
success |
status-success-dot |
Green | No |
warning |
status-warning-dot |
Amber | No |
error |
status-error-dot |
Red | No |
neutral |
status-neutral-dot |
Grey | No |
urgent |
status-urgent-dot |
Purple | Yes (blocked tasks, urgent inbox) |
Note: pulse is a prop, not automatic per status. The table above shows
conventional usage. A completed agent does not pulse; a health dot in green
state pulses briefly (3s) then stops — that logic lives in the consuming
component, not in StatusDot itself.
Styling
.status-dot {
display: inline-block;
border-radius: 9999px; /* fully round */
flex-shrink: 0; /* never collapse in flex layouts */
background: hsl(var(--status-{status}-dot));
}
.status-dot--sm { width: 6px; height: 6px; }
.status-dot--md { width: 8px; height: 8px; }
.status-dot--lg { width: 10px; height: 10px; }
.status-dot--pulse {
animation: status-pulse 2s ease-in-out infinite;
}
@keyframes status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
Usage
| Page | Context | Status | Size | Pulse? |
|---|---|---|---|---|
| App header | Health indicator | success/warning/error | lg | Green: 3s then stop |
| Agents | Agent card status | active/warning/error/neutral | md | active: yes |
| Execution tab | Phase/task status | active/success/warning/neutral | md | active: yes |
| Inbox | Conversation priority | urgent/warning/neutral/success | md | urgent: yes |
| Dialogs | Task dependency status | varies | sm | No |
| Settings | Account health | success/warning | sm | No |
Accessibility
role="status"+aria-labelon every instance- Color is never the sole indicator — always paired with text label or icon in context
- Pulse animation respects
prefers-reduced-motion: reduce(disable animation)
Source
packages/web/src/components/StatusDot.tsx(proposed)
7. <SkeletonLoader>
Shimmer placeholder blocks shown during data loading. Mirrors the anatomy of the content it replaces to prevent layout shift. Used on every data-fetching page.
Props
interface SkeletonProps {
/**
* Shape variant:
* - 'line': horizontal bar (text placeholder). Default height 16px.
* - 'circle': avatar / status dot placeholder. Uses `size` for diameter.
* - 'rect': block placeholder (card, image area). Uses `width` x `height`.
*/
variant?: 'line' | 'circle' | 'rect';
/** Width. For 'line': defaults to '100%'. For 'circle': ignored (uses size). */
width?: string | number;
/** Height. For 'line': defaults to 16px. For 'circle': ignored (uses size). */
height?: string | number;
/** Diameter for 'circle' variant. Defaults to 32px. */
size?: number;
/** Additional className for width/margin overrides. */
className?: string;
}
/**
* Composite skeleton that renders N skeleton cards matching a specific
* page's card anatomy. Use instead of manually assembling Skeleton primitives.
*/
interface SkeletonCardProps {
/** Number of skeleton cards to render. Default: 5. */
count?: number;
/** Card layout variant matching the page being loaded. */
layout: 'agent-card' | 'initiative-card' | 'conversation-card' | 'project-card' | 'account-card';
}
Shimmer Animation
All skeleton elements share a single shimmer sweep: a translucent highlight
gradient that moves left-to-right on a 1.5s infinite loop. This uses a CSS
background-position animation on a linear gradient, not a pseudo-element
overlay, so it works on any shape.
Resting: ░░░░░░░░░░░░░░░░░░░░
Shimmer: ░░░░░▓▓▓░░░░░░░░░░░░ <- highlight sweeps right
Shimmer: ░░░░░░░░░░░░▓▓▓░░░░░
.skeleton {
background: linear-gradient(
90deg,
hsl(var(--muted)) 25%,
hsl(var(--muted-foreground) / 0.08) 50%,
hsl(var(--muted)) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Uses --duration-glacial (500ms) as the animation timing reference from
the theme spec, though the actual cycle is 1.5s (3x glacial) for a gentler feel.
Composite Card Skeletons
Agent card skeleton (matches agents.md card anatomy):
+---------------------------+
| [.] ░░░░░░░░░░░░ | <- circle (8px) + line (60%)
| ░░░░░ . ░░░░░░ | <- line (40%) + dot + line (50%)
| ░░░░░░░░░░░░░ | <- line (70%)
| ░░░░ ░░░ ░░░ | <- line (30%) + gap + line (20%) + line (20%)
+---------------------------+
Initiative card skeleton (matches initiatives-list.md card anatomy):
+---------------------------+
| ░░░░░░░░░░░░░░░░ ░░ ░░ | <- line (65%) + badge + badge
| ░░░░░░░░░░ ░░░░░ | <- line (40%) + line (30%)
| ░░░░░░░░░░░░░░░░░░░░░░░ | <- rect (100% x 6px) progress bar
| ░░░░░░░░ ░░░░░ ░░░░ | <- line (35%) + line (25%) + line (20%)
+---------------------------+
Conversation card skeleton (matches inbox.md card anatomy):
+---------------------------+
| [.] ░░░░░░░░░░ ░░░ | <- circle (8px) + line (55%) + line (15%)
| ░░░░░░░░░░░░░░░░░░ | <- line (80%)
| ░░░░░░░░░░░░ | <- line (50%)
+---------------------------+
Loading-to-Content Transition
- Skeletons fade out:
opacity-0over 150ms (duration-fast) - Content fades in:
opacity-100over 150ms - No layout jump: skeleton card dimensions must exactly match real card dimensions
- Container uses
min-hto prevent collapse during transition - On error: skeletons fade out,
<ErrorState>fades in centered
Accessibility
aria-hidden="true"on all skeleton elements (they are decorative)role="status"+aria-label="Loading"on the skeleton container- Respects
prefers-reduced-motion: reduce— disable shimmer, show static grey blocks
Usage
| Page | Card Layout | Count | Notes |
|---|---|---|---|
| Initiatives list | initiative-card |
5 | Matches card height to prevent jump |
| Agents | agent-card |
5 | Output panel shows empty state, not skeleton |
| Inbox | conversation-card |
6 | List panel only |
| Settings > Projects | project-card |
3 | |
| Settings > Accounts | account-card |
3 | |
| Initiative detail | Custom | 1 | Two-row header + tab bar + content area |
| Content tab | Custom | 1 | Sidebar tree + editor area |
Source
packages/web/src/components/Skeleton.tsx(proposed — primitives)packages/web/src/components/SkeletonCard.tsx(proposed — composite cards)
8. <Toast> (via Sonner)
Global notification toasts for asynchronous feedback. Rendered by the
<Toaster> provider at the app root. Individual toasts are fired imperatively
via toast(), toast.success(), toast.error(), etc.
This project uses Sonner (already a shadcn/ui dependency). No custom toast component is needed — this spec documents the configuration and usage conventions.
Toaster Configuration
// In app root layout (see app-layout.md)
<Toaster
position="bottom-right"
toastOptions={{
className: 'font-sans text-sm',
duration: 5000, // default auto-dismiss: 5s
}}
visibleToasts={3} // max 3 visible, older collapse to "+N more"
richColors // enable semantic color variants
closeButton // always show dismiss X
offset={16} // 16px from viewport edge
/>
Toast Variants
Success: +---------------------------------------+
| [v] Account added [x] |
+---------------------------------------+
bg-status-success-bg, auto-dismiss 5s
Error: +---------------------------------------+
| [!] Failed to delete project [x] |
| [Retry] |
+---------------------------------------+
bg-status-error-bg, persistent (no auto-dismiss)
Info: +---------------------------------------+
| [i] Answer sent to blue-fox-7 [x] |
+---------------------------------------+
default bg, auto-dismiss 5s
Warning: +---------------------------------------+
| [!] Account X exhausted, [x] |
| switched to Y |
+---------------------------------------+
bg-status-warning-bg, auto-dismiss 5s
With action:+---------------------------------------+
| [!] blue-fox-7 crashed [x] |
| [View Agent] |
+---------------------------------------+
persistent, action navigates to /agents
Duration Rules
| Category | Duration | Rationale |
|---|---|---|
| Success confirmations | 5s | User glances, moves on |
| Info / status changes | 5s | FYI, not blocking |
| Warnings (account switch, etc.) | 5s | Notable but not actionable |
| "All tasks complete" | 8s | Celebration moment, give it room |
| "Agent has a question" | 8s | Needs attention but not emergency |
| Agent crashed | Persistent | Action required — must be manually dismissed |
| Task needs approval | Persistent | Action required |
| Errors with retry | Persistent | User must acknowledge or retry |
Critical Event Toasts (fired globally via EventBus)
These fire regardless of the current page (configured in app-layout.md):
| Event | Toast Call | Duration |
|---|---|---|
| Agent crashed | toast.error("blue-fox-7 crashed", { action: { label: "View", onClick: navigate } }) |
Persistent |
| Task needs approval | toast("Task ready for approval", { action: { label: "Review", onClick: navigate } }) |
Persistent |
| All tasks complete | toast.success("Initiative complete — ready for review") |
8s |
| Account exhausted | toast.warning("Account X exhausted, switched to Y") |
5s |
| Agent asking question | toast("blue-fox-7 has a question", { action: { label: "Answer", onClick: navigate } }) |
8s |
Stacking Behavior
+---+
(viewport bottom-right) | +N | <- collapsed count
+---+
+---------------------------+
| Toast 3 (oldest visible) |
+---------------------------+
+---------------------------+
| Toast 2 |
+---------------------------+
+---------------------------+
| Toast 1 (newest) |
+---------------------------+
- Max 3 visible; older toasts collapse into a "+N more" pill
- New toasts enter from the bottom with
--ease-spring(slight overshoot) - Dismissed toasts slide out to the right with
--ease-in - Z-index:
var(--z-toast)(60) — above modals, below command palette
Source
packages/web/src/components/ui/sonner.tsx(shadcn/ui generated — configure here)- Toaster mount point:
packages/web/src/App.tsxor root layout
9. <Badge>
Inline status label rendered as a small colored pill. Used for entity status (RUNNING, ACTIVE, BLOCKED), category labels (execute, verify), tab counts, and filter chips.
Props
interface BadgeProps {
/** Visual variant controlling color scheme. */
variant:
| 'active' // blue bg — running, in_progress, building
| 'success' // green bg — completed, connected, done
| 'warning' // amber bg — waiting, exhausted, pending_approval
| 'error' // red bg — crashed, failed, disconnected
| 'neutral' // grey bg — stopped, idle, pending, exited
| 'urgent' // purple bg — blocked
| 'outline' // border-only, no fill — provider labels, category tags
| 'secondary'; // muted fill — mode labels, secondary metadata
/** Badge text content. Rendered uppercase in status badges, as-is for categories. */
children: React.ReactNode;
/** Size variant. Default: 'sm'. */
size?: 'sm' | 'xs';
/** Optional leading icon (rendered at 12px for xs, 14px for sm). */
icon?: React.ReactNode;
/**
* When true, text is rendered in uppercase with letter-spacing.
* Use for status badges (RUNNING, ACTIVE). Default: false.
*/
uppercase?: boolean;
}
Visual Variants
Status badges (uppercase=true):
[RUNNING] <- variant="active", blue bg
[ACTIVE] <- variant="active", blue bg
[DONE] <- variant="success", green bg
[WAITING] <- variant="warning", amber bg
[CRASHED] <- variant="error", red bg
[STOPPED] <- variant="neutral", grey bg
[BLOCKED] <- variant="urgent", purple bg
[QUEUED] <- variant="active", blue bg
Category badges (uppercase=false, variant="outline"):
[execute] <- outline, no fill
[verify] <- outline, no fill
[research] <- outline, no fill
Provider/mode badges (uppercase=false):
[claude] <- variant="outline"
[execute] <- variant="secondary"
Tab count badges (size="xs"):
[*3] <- variant="active", xs size (agents tab)
[2] <- variant="warning", xs size (inbox tab)
Filter chips (with dismiss):
[running x] <- variant="active" with trailing X button
Size Variants
| Size | Font | Padding | Border Radius | Line Height |
|---|---|---|---|---|
sm (default) |
text-xs (12px) |
px-2 py-0.5 |
rounded-sm (2px) |
20px |
xs |
text-[10px] |
px-1.5 py-px |
rounded-sm (2px) |
16px |
Color Mapping (uses status tokens from theme)
| Variant | Background | Text | Border |
|---|---|---|---|
active |
bg-status-active-bg |
text-status-active-fg |
border-status-active-border |
success |
bg-status-success-bg |
text-status-success-fg |
border-status-success-border |
warning |
bg-status-warning-bg |
text-status-warning-fg |
border-status-warning-border |
error |
bg-status-error-bg |
text-status-error-fg |
border-status-error-border |
neutral |
bg-status-neutral-bg |
text-status-neutral-fg |
border-status-neutral-border |
urgent |
bg-status-urgent-bg |
text-status-urgent-fg |
border-status-urgent-border |
outline |
transparent |
text-foreground |
border border-border |
secondary |
bg-secondary |
text-secondary-foreground |
none |
Entity-to-Variant Mapping
Use this table when deciding which variant to assign. This is the canonical source — do not invent new mappings without adding them here.
| Entity | State | Variant | Label |
|---|---|---|---|
| Agent | running | active |
RUNNING |
| Agent | waiting_for_input | warning |
WAITING |
| Agent | completed | neutral |
COMPLETED |
| Agent | crashed | error |
CRASHED |
| Agent | stopped | neutral |
STOPPED |
| Task | in_progress | active |
RUNNING |
| Task | completed | success |
DONE |
| Task | pending | neutral |
PENDING |
| Task | pending_approval | warning |
APPROVAL |
| Task | blocked | urgent |
BLOCKED |
| Task | queued | active |
QUEUED |
| Task | failed | error |
FAILED |
| Initiative | active | active |
ACTIVE |
| Initiative | completed | success |
COMPLETED |
| Initiative | archived | neutral |
ARCHIVED |
| Initiative mode | review | warning |
REVIEW |
| Initiative mode | planning | secondary |
PLANNING |
| Preview | building | active |
BUILDING |
| Preview | running | success |
RUNNING |
| Preview | failed | error |
FAILED |
| Preview | stopped | neutral |
STOPPED |
| Account | active | success |
-- (no badge) |
| Account | exhausted | warning |
EXHAUSTED |
Accessibility
- Badge text is readable by screen readers (no
aria-hidden) - Color is never the sole differentiator — the text label provides meaning
- Sufficient contrast ratios are ensured by the status token pairs (bg/fg)
Source
packages/web/src/components/Badge.tsx(proposed — or extend shadcn/uibadge.tsx)
10. <KeyboardShortcutHint>
Tooltip component that displays the keyboard shortcut for an action. Used in
button tooltips, command palette results, and the ? shortcut help overlay.
Props
interface KeyboardShortcutHintProps {
/** The key combination. Platform-aware: automatically swaps Cmd/Ctrl.
* Format: modifier keys joined with '+'. Examples: "Cmd+K", "Shift+N", "Esc", "1"
*/
keys: string;
/** Tooltip text shown before the shortcut. e.g., "Search" renders as "Search ⌘K" */
label?: string;
/**
* Rendering mode:
* - 'tooltip': renders inside a shadcn Tooltip (default). Wraps `children`.
* - 'inline': renders as inline <kbd> elements (for command palette results, help overlay).
*/
mode?: 'tooltip' | 'inline';
/** The element that triggers the tooltip. Only used in 'tooltip' mode. */
children?: React.ReactNode;
}
Platform Detection
// Detect platform once at module level
const isMac = navigator.platform?.startsWith('Mac') ||
navigator.userAgent?.includes('Mac');
// Key symbol mapping
const KEY_SYMBOLS: Record<string, string> = {
'Cmd': isMac ? '⌘' : 'Ctrl',
'Ctrl': isMac ? '⌃' : 'Ctrl',
'Alt': isMac ? '⌥' : 'Alt',
'Shift': '⇧',
'Enter': '↵',
'Backspace': '⌫',
'Esc': 'Esc',
'Tab': '⇥',
'ArrowUp': '↑',
'ArrowDown': '↓',
'ArrowLeft': '←',
'ArrowRight':'→',
};
Tooltip Mode (default)
Wraps a trigger element with a shadcn <Tooltip>. The tooltip content
shows label (if provided) followed by the key combination in <kbd> pills.
Hovering over the Cmd+K button:
+-----+
| ⌘K | <- trigger element (the button)
+-----+
|
+-------------+
| Search ⌘ K | <- tooltip: label + kbd pills
+-------------+
Hovering over a tab:
+----------+
| Content | <- trigger (tab)
+----------+
|
+-----------+
| Content 1 | <- tooltip: label + key
+-----------+
Inline Mode
Renders <kbd> pills directly in the flow. Used in command palette results
and the keyboard shortcut help overlay.
Command palette result with shortcut:
> Go to Agents ⌘ 2
^^^
inline <kbd> pills
Shortcut help overlay row:
Next file j
Previous file k
Toggle resolve r
Search ⌘ K
<kbd> Pill Styling
Individual key:
+---+
| K | <- single character key
+---+
+------+
| Ctrl | <- modifier key (text on non-Mac)
+------+
+---+
| ⌘ | <- modifier key (symbol on Mac)
+---+
- Each key segment is a separate
<kbd>element <kbd>styling:inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-sm bg-muted border border-border text-[11px] font-mono text-muted-foreground leading-none- Multiple keys separated by
gap-0.5(2px) - Modifier+key combinations:
⌘K(two separate pills, no+separator)
Usage
| Location | Mode | Keys | Label |
|---|---|---|---|
| Header Cmd+K button | tooltip | Cmd+K |
"Search" |
| Tab tooltips (1-4) | tooltip | 1, 2, 3, 4 |
Tab name |
| Command palette results | inline | varies | -- |
? shortcut help overlay |
inline | all shortcuts | Action name |
| Inbox send button | inline | Cmd+Enter |
-- |
| Plan tab add task | tooltip | N |
"New task" |
Accessibility
<kbd>elements are semantic HTML — screen readers announce them as keyboard input- Tooltips use
role="tooltip"via shadcn Tooltip - Platform-specific symbols (⌘, ⌃, ⌥) have appropriate
aria-labelfallbacks (aria-label="Command",aria-label="Control",aria-label="Option")
Source
packages/web/src/components/KeyboardShortcutHint.tsx(proposed)
Cross-Reference Index
Quick lookup of which shared components are used on each page.
| Component | app-layout | initiatives-list | initiative-detail | content-tab | plan-tab | execution-tab | review-tab | agents | inbox | settings |
|---|---|---|---|---|---|---|---|---|---|---|
| SaveIndicator | x | x | ||||||||
| EmptyState | x | x | x | x | x | x | ||||
| ErrorState | x | x | x | x | x | x | ||||
| CommandPalette | x | |||||||||
| ThemeToggle | x | |||||||||
| StatusDot | x | x | x | x | x | |||||
| SkeletonLoader | x | x | x | x | x | x | ||||
| Toast | x | x | x | x | ||||||
| Badge | x | x | x | x | x | x | ||||
| KeyboardShortcutHint | x | x | x | x | x | x | x | x |
Design Review Notes
Existing components: what was already solid
-
SaveIndicator — the
persistentprop with timestamp display is exactly right. High-stakes contexts (page content editing) need ongoing confirmation; lightweight contexts (phase descriptions) do not. TheIntl.DateTimeFormatcall is a good detail. One nit: consider adding afadeDurationprop (default 2000ms) for contexts where the fade timing needs tuning. Not critical — the current hardcoded 2s is fine for now. -
EmptyState — the dual-action pattern with primary/secondary buttons is well spec'd. The button variant assignments (default vs outline) are correct. The usage table covering 7 pages with specific copy is useful for implementation. The "without description" variant prevents over-engineering simple empty states.
-
ErrorState — the expandable details section with
<details>/<summary>is the right call. Using native HTML disclosure is simpler than a state toggle and works without JS. TheerrorCodedisplayed infont-monois good for bug reports. One gap: there is no "Copy error details" button for users filing issues. Consider adding a clipboard-copy icon inside the details panel. -
CommandPalette — this section is thorough. The fuzzy matching behavior (whitespace-split AND-logic, no typo tolerance) is a good conservative default for a precision-oriented tool. The result ranking (prefix > position > recency) is sound. Recent items in
sessionStoragewith LRU eviction is correct — these should not persist across sessions. The "no results" fallback with static navigation links prevents dead ends. -
ThemeToggle — the dynamic
"System (Dark)"/"System (Light)"tooltip resolvingprefers-color-schemeis the right UX. Users who pick "System" and see dark mode need to know it is working as intended, not broken. ReadingisDarkfrom ThemeProvider context is the clean implementation path.
Added components: rationale
-
StatusDot — this was the most glaring omission. The dot appears on every data-driven page, in at least 6 different contexts, and was being described ad-hoc in agents.md, app-layout.md, inbox.md, execution-tab.md, and dialogs.md. Without a shared spec, each page would implement its own dot with slightly different sizes, animations, and accessibility. The
pulseprop is intentionally not automatic per status — the consuming component decides (e.g., health dot pulses for 3s then stops; agent dot pulses indefinitely). Thelabelprop is required, not optional, because status-by-color-alone fails WCAG. -
SkeletonLoader — every wireframe describes its own skeleton layout but none referenced a shared primitive. The composite
SkeletonCardpattern (with named layouts likeagent-card,initiative-card) prevents skeleton/content dimension mismatches that cause layout jumps. The shimmer animation is standardized at 1.5s with theme token references. -
Toast — app-layout.md already described Sonner configuration and critical event toasts, but scattered across two sections. Centralizing the duration rules, stacking behavior, and variant usage in one place prevents inconsistency. The duration table (5s default, 8s for attention, persistent for action-required) is a policy decision that should live in a shared spec, not be reinvented per page.
-
Badge — the most polymorphic component in the system. It appears as status badges (RUNNING), category labels (execute), tab counts (*3), provider labels (claude), and filter chips. Without a canonical variant-to-color mapping, every developer picks their own colors. The entity-to-variant table is the single source of truth — if a status is not in that table, it does not get a badge.
-
KeyboardShortcutHint — a keyboard-first tool that does not show its shortcuts is just a tool. The
<kbd>pill styling with platform-aware symbols (⌘ vs Ctrl) is table stakes. The dual mode (tooltip wrapping trigger elements vs inline in command palette results) covers both discovery patterns. Thearia-labelfallbacks on Mac symbols prevent screen readers from announcing "unknown character."
Open questions for implementation
-
StatusDot
pulseandprefers-reduced-motion: The spec says disable animation, but should it fall back to a static solid dot or a subtly different visual (e.g., a ring outline)? A static dot for a running agent looks identical to a completed agent to users with reduced-motion preferences. Recommendation: use a double-ring outline as the reduced-motion fallback forpulse=true. -
Badge filter chips: The spec mentions
[running x]with a dismiss button but does not fully define the dismiss interaction. This depends on the filter bar implementation in initiatives-list.md and agents.md. Defer the dismissible badge variant until those filter patterns are finalized. -
SkeletonCard dimension matching: The spec says skeleton dimensions must match real card dimensions. In practice, this requires the skeleton and card to share a CSS class or dimension constant. Consider extracting card height values into CSS custom properties (e.g.,
--card-height-agent: 112px) referenced by both the real card and its skeleton.