Files
Claude-Code-Monitor/docs/superpowers/specs/2026-05-28-tabby-companion-design.md
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

16 KiB

Tabby — Floating Companion (Design Spec)

Date: 2026-05-28 Status: Approved (design) — pending spec review before planning Owner: Nguyễn Ngọc Trí Vĩ (David) Topic: A cute-but-functional cat companion that lives in the dashboard's bottom corner, reacts to live session events, and expands into a panel for status, quick actions, and asking questions.


1. Summary

Tabby is a floating cat avatar pinned to the bottom-right corner of the Agent Dashboard on every route. It is two things at once:

  1. A reactive mascot — an SVG cat whose face, ears, eyes, and posture react in real time to what the monitored Claude Code sessions are doing (a session finishes → tail-up, eyes ^^; an error/hook fails → arch + ears-back; idle → curls up asleep). Eyes track the cursor when alert.
  2. An assistant — click the avatar (or press ⌘B / Ctrl+B) to expand a panel with a live status line, quick navigation actions, and an Ask box that answers simple questions from cached dashboard data, with a handoff to the existing Run page to ask Claude for real.

The "do the job" path reuses what already exists: POST /api/run spawns a real claude subprocess and streams over WebSocket. Tabby does not introduce any new LLM backend, API key, or server route in P1/P2. P3 adds a single client-only deep-link prefill.

Name Tabby matches the app's identity: this is a Monitor ("watching your agents"), and Tabby is the alert watcher curled in the corner.


2. Goals / Non-Goals

Goals

  • Delightful, on-theme personality layer over live session data — "cute but does the job."
  • Always-present, low-footprint corner avatar that auto-surfaces notable events as transient speech bubbles, then settles.
  • One-keystroke (⌘B) expand to a functional panel: status, quick actions, local Ask.
  • Reuse the existing event stream (eventBus) and Run flow — no new backend in P1/P2.
  • Fully consistent with the existing dark Tailwind theme (surface-*, accent, border).
  • Accessible: keyboard-operable, aria-live bubbles, honors prefers-reduced-motion.
  • Degrades safe: if WebSocket is down/delayed, Tabby shows a calm/disconnected state — never errors, never blocks the page.

Non-Goals (YAGNI)

  • No drag-to-reposition (fixed bottom-right).
  • No sound effects.
  • No new LLM/chat backend or API key (Ask is rule-based locally; real Claude = handoff to Run).
  • No server-side persistence (preferences in localStorage only).
  • No multi-avatar / skins / customization.
  • No changes to existing pages beyond the minimal mount + the P3 Run prefill.

3. Where it lives (architecture)

App.tsx
  └─ useWebSocket(onMessage = eventBus.publish)   // single shared socket, already exists
  └─ Layout.tsx
        ├─ UpdateNotifier        (existing global floater)
        ├─ Tabby   ◀── NEW: mounted here, sibling of UpdateNotifier
        └─ <Outlet/>             (page routes)
  • Mount point: client/src/components/Layout.tsx, right next to <UpdateNotifier/>. This guarantees Tabby persists across every route and shares the one WebSocket connection.
  • Data source: the existing eventBus (client/src/lib/eventBus.ts).
    • eventBus.subscribe(handler) → every WSMessage.
    • eventBus.onConnection(handler) + eventBus.connected → WS up/down.
    • No prop drilling, no new context provider. The brain hook subscribes directly.
  • Navigation: quick actions use react-router (useNavigate) to jump to existing routes (/sessions, /sessions/:id, /activity, /run).

Component layout (new, isolated directory)

client/src/components/Tabby/
  Tabby.tsx          # Container. Owns open/collapsed/muted state, ⌘B + Esc handlers,
                     #   localStorage persistence. Composes the three presentational parts.
  CatAvatar.tsx      # Pure presentational SVG cat. Props: { mood, eyeTarget, reducedMotion }.
                     #   No data access — fully testable in isolation.
  SpeechBubble.tsx   # Transient bubble. Props: { text, onDismiss }. aria-live="polite",
                     #   auto-dismiss ~4.5s. No data access.
  TabbyPanel.tsx     # Expanded panel: status header + quick actions + Ask box.
                     #   Receives status summary + handlers as props.
  useTabbyBrain.ts   # The brain. Subscribes eventBus → derives { mood, statusSummary,
                     #   bubbleQueue }. Owns all timers (idle/sleep/stuck). The only unit
                     #   that touches eventBus.
  intents.ts         # Local Ask: maps a free-text question → templated answer from cached
                     #   status, or a { runHandoff: prompt } signal. Pure function.
  quips.ts           # mood/event → randomized phrase pool. The personality. Pure data + picker.
  tabby.css          # Keyframes (breathe/blink/ear-twitch/arch/tail-flick), translucency,
                     #   prefers-reduced-motion overrides.

Boundaries / contracts:

  • useTabbyBrain is the only unit that subscribes to eventBus. Everything else receives plain props. This keeps the live-data surface in one place and the rest trivially testable.
  • CatAvatar, SpeechBubble, TabbyPanel are pure presentational components — given props, render UI. No side effects.
  • intents.ts and quips.ts are pure functions over inputs — unit-testable with no DOM.

4. Data flow

server broadcast ──► useWebSocket ──► eventBus.publish ──► useTabbyBrain subscriber
                                                                │
                          (reduce WSMessage + timers into state)│
                                                                ▼
                                            { mood, statusSummary, bubbleQueue }
                                                                │
                 ┌──────────────────────────────┬──────────────┴───────────────┐
                 ▼                                ▼                              ▼
            CatAvatar(mood)              SpeechBubble(next bubble)        TabbyPanel(statusSummary)
                                                                                │
                                                                  quick action  │  Ask
                                                                                ▼
                                                              useNavigate(route)  |  intents() → answer
                                                                                              | or → /run?prompt=

useTabbyBrain maintains a small in-memory model derived from the stream (it does not refetch):

  • liveCount — active sessions/agents currently working.
  • errorCount — sessions/agents in error since last clear.
  • lastEventAt — timestamp of most recent new_event/update (drives stuck/sleeping).
  • connected — from eventBus.onConnection.
  • recentDone — transient flag set on a session_updated → status completed, cleared after the happy animation.

The exact WSMessage.type union the brain switches on (from client/src/lib/types.ts): session_created, session_updated, agent_created, agent_updated, new_event, import.progress, update_status, run_stream, run_status, run_input_ack, cc_config_changed. Tabby only cares about: session_created/session_updated/agent_created/agent_updated (mood + counts), new_event (activity heartbeat → lastEventAt, and hook-failure detection via the event payload), run_status (run finished → happy). The rest are ignored.

These feed both the avatar mood and the panel's status line. Counts are best-effort from the stream; the panel may also read a one-shot from existing stats endpoints if needed for an accurate initial number (open item — see §10).


5. Mood state machine (rule-based brain)

Mood is a pure function of (streamModel, timers), evaluated on every relevant event and on timer ticks. Highest-priority matching state wins:

Priority Mood Trigger Cat expression
1 disconnected WS down (eventBus.connected === false) faded/desaturated, flat ears, still
2 worried session_updated/agent_updated with status error, or a hook-failure new_event arch + puff, ears back, brow down, brief shake
3 stuck ≥1 live session AND now - lastEventAt > STUCK_MS ears-up alert stare, !
4 happy session_updatedcompleted, or run_status finished (transient, ~4s) tail-up, eyes ^^, head-bob
5 thinking Ask in flight (panel) head-tilt,
6 watching ≥1 live session, recent activity eyes track cursor, ears up, tail flick
7 sleeping no activity AND idle > SLEEP_MS curled, eyes shut, zzz
8 idle default / fallback slow blink, gentle breathe

Constants (tunable, defined in useTabbyBrain): STUCK_MS (~10 min), SLEEP_MS (~3 min). All timers cleared on unmount.

Event → mood mapping (concrete):

  • onConnection(true) → recompute (leaves disconnected).
  • onConnection(false)disconnected.
  • session_updated data.status errorworried (+ increment errorCount).
  • session_updated data.status completedhappy (transient) + decrement liveCount.
  • session_created / session_updated data.status activewatching, recompute liveCount.
  • agent_updated status errorworried.
  • new_event → refresh lastEventAt; hook-failure event types (confirm in build, see §10) → worried.
  • run_status finished → happy (transient).
  • (timers) inactivity → stuck (if live) or sleeping (if not).

6. Eyes & motion

  • Eye tracking (watching/idle): pupils follow the mouse, clamped inside the eye socket via a small vector-normalize + clamp. Throttled (rAF or ~30ms) to stay cheap.
  • On event: eyes glance toward the bubble, then relax back to tracking.
  • Ears/tail/body: CSS keyframe animations in tabby.css, swapped by a data-mood attribute on the avatar root.
  • prefers-reduced-motion: static eyes (centered), no breathe/shake/arch — mood still conveyed via static pose + face. Detected via matchMedia, passed as reducedMotion prop.

7. Auto-surface (speech bubbles)

  • Pipeline: event → quips.pick(mood/event) → enqueue bubble → show ~4.5s → dismiss → settle.
  • Rate limit: at most one bubble every few seconds; coalesce bursts ("3 sessions finished" instead of three bubbles).
  • Mute toggle: persisted in localStorage. Muted = no bubbles, but faces/animations still react. Toggle lives in the panel.
  • Accessibility: bubble container is aria-live="polite" so screen readers announce notable events without stealing focus.

Example quips (from quips.ts, randomized):

  • happy: "session wrapped 🐾", "nice, that one's done", "4m12s — clean run"
  • worried: "ow, an error", "a hook tripped — peek?"
  • stuck: "this one's been quiet a while…", "still chewing on something?"
  • sleeping: "zzz", "wake me if something happens"

8. Panel (click / ⌘B)

Opens as a small card anchored above the avatar. Themed with surface-3/border/accent.

Status header: 🐾 N live · M errored · ●connected (from brain's statusSummary; reflects WS state, colored by health).

Quick actions (each = useNavigate to an existing route, or a local toggle):

  • Jump to errored session → /sessions/:id (most recent error) or /sessions?status=error.
  • Active sessions → /sessions (or /activity).
  • Run Claude/run.
  • Activity feed → /activity.
  • Mute / unmute bubbles (local toggle, persisted).
  • Clear alerts (reset errorCount).

Ask box:

  • P1/P2: intents() matches the query against a small set of local intents over cached status — e.g. what's running, any errors, how many today, slowest — and returns a templated answer rendered in the panel.
  • Unmatched query → offer: "Ask Claude directly?" → opens /run?prompt=<query> (P3).

Dismiss: Esc, click-outside, or re-press ⌘B.


9. Phasing

P1 — Mascot (delight, zero backend)

  • CatAvatar.tsx (full SVG + all moods + eye tracking + reduced-motion).
  • useTabbyBrain.ts (eventBus subscription, mood machine, timers, bubble queue).
  • SpeechBubble.tsx, quips.ts, tabby.css.
  • Tabby.tsx container mounting avatar + bubble; ⌘B reserved but panel stubbed.
  • Mounted in Layout.tsx.
  • Outcome: living, reacting cat in the corner with auto-bubbles. No panel yet.

P2 — Panel (functional)

  • TabbyPanel.tsx: status header + quick actions (router nav) + local Ask.
  • intents.ts local intent matching.
  • localStorage for collapsed + muted; mute/clear in panel.
  • Settings.tsx: a single on/off toggle for Tabby (persisted), read by Tabby.tsx.
  • Outcome: click/⌘B opens a useful panel; Ask answers from local data.

P3 — "Do the job" handoff

  • Run.tsx: read ?prompt= search param → setPrompt(prefill) on mount (mirrors the existing ?session= pattern). Client-only, no server change.
  • Wire Ask's unmatched-query path → /run?prompt=<query>.
  • Outcome: Tabby can hand a real question to a real claude subprocess via the existing Run flow.

10. Open items (resolve during planning/build)

  1. Accurate initial counts: the stream gives deltas; on first mount counts are unknown until events arrive. Decide: (a) start at 0 and let the stream fill in (simplest), or (b) one-shot read from the existing stats endpoint for an accurate seed. Leaning (a) for P1, optional (b) in P2 panel.
  2. Hook-failure detection: confirm which event eventType values represent hook failures vs. normal lifecycle, so worried only fires on real problems. Verify against server/routes/hooks.js + DB event types during build.
  3. Errored-session deep link: confirm /sessions supports a status=error query or whether to navigate to the specific /sessions/:id.

11. Theme & accessibility notes

  • Colors strictly from existing tokens: surface-0..5, border/border-light, accent/accent-hover. Cat palette: warm accent-tinted body that reads on the dark surface-0 background; soft glow via accent-muted.
  • Fonts inherit (Inter / JetBrains Mono) — bubble/status text uses existing classes.
  • Keyboard: ⌘B/Ctrl+B toggle, Esc close, panel actions tab-focusable.
  • prefers-reduced-motion: disables continuous animation.
  • z-index above content, below modals; never traps focus when collapsed.

12. Verification (per CLAUDE.md)

  • Frontend: npm run test:client.
    • Unit tests for useTabbyBrain mood transitions (each event → expected mood, priority ordering, timer-driven stuck/sleeping).
    • Unit tests for intents() (known queries → templated answers; unknown → runHandoff).
    • Unit test for quips.pick (returns a string for every mood).
  • No server change in P1/P2npm run test:server not required for those phases. P3 touches only Run.tsx (client) → still client-only; run test:client.
  • Manual: load dashboard, trigger a run, observe mood/bubble transitions; toggle reduced-motion; toggle mute; ⌘B/Esc.

13. File change summary

New: client/src/components/Tabby/{Tabby,CatAvatar,SpeechBubble,TabbyPanel}.tsx, client/src/components/Tabby/{useTabbyBrain.ts,intents.ts,quips.ts,tabby.css}, plus __tests__ for brain/intents/quips. Edited: client/src/components/Layout.tsx (mount, P1) · client/src/pages/Settings.tsx (on/off toggle, P2) · client/src/pages/Run.tsx (?prompt= prefill, P3) · i18n files (tabby:* keys, as strings are added).