diff --git a/docs/superpowers/specs/2026-07-23-agent-activity-legibility-design.md b/docs/superpowers/specs/2026-07-23-agent-activity-legibility-design.md new file mode 100644 index 0000000000..ef013aae22 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-agent-activity-legibility-design.md @@ -0,0 +1,690 @@ +# Agent-Activity Legibility — Design Spike + +- **Date:** 2026-07-23 +- **Branch:** `chief/bc-agent-activity-design` (chief run, lane N) +- **Status:** Design only — no implementation. Owner approves before any code lane spawns. +- **Scope:** local deployments, claude executor + CLI/native sessions (see Non-goals) +- **Builds on:** [`2026-07-20-cli-ui-seamless-sync-design.md`](2026-07-20-cli-ui-seamless-sync-design.md) + (PR #38 / #39 / #41). This doc is essentially that spike's deferred + **"Phase 3 — presence & polish"** (§8 there), expanded, plus the subagent + surface it explicitly left out (§5 there: *"Exclude `agent-*` files (subagent + sidechains) in v1"*). +- **All file:line refs at `origin/main` @ `0fbc0965`.** + +## 1. Goal — one theme: the web UI does not make agent activity legible + +The owner named three things. They are one theme — *when an agent (main or sub) +is doing something, the web UI should show it* — split across three facets: + +1. **(NEW, most urgent) Processing indicator.** Verbatim: *"on UIX mode you + don't know when the agent is doing/processing. that's not good."* There is no + clear, in-view cue that the agent is actively working/thinking/streaming — + specifically in CLI ("UIX") mode. +2. **Realtime subagent tabs.** Verbatim: *"tabs on cli tracking in realtime, + subagents being spawned as tabs."* Surface spawned subagents live. Owner's own + nuance, honored throughout: *"Claude Code spawns many subagents but then + they're inactive, so maybe also a small button to check inactive subagents but + not there as tabs by default."* → **ACTIVE subagents as tabs; INACTIVE ones + behind a button, not tabs by default.** +3. **Main-vs-subagent identity.** Generalize the identity/presence model that the + quarantine panel and session labeling already reach for (concurrent lanes L/M), + rather than re-spec that panel. + +After this design ships, in one sentence: **at any moment the person in the web +UI can tell (a) whether the agent is working right now, (b) which subagents are +running right now, and (c) who produced any given turn — main agent, a named +subagent, or the CLI vs the app** — on both desktop and mobile, without the tab +strip blowing up. + +### 1.1 The coarse facet-1 fix is already a separate lane (Lane O) + +The urgent, coarse half of facet 1 — *"is the agent working right now"* on the +in-view CLI surface — is **shipping separately and immediately as Lane O +(`chief/bc-cli-working-indicator`)**. This spike's investigation established that +the running signal already runs end-to-end (§4.4, §5.4), so no backend is needed; +the boss pulled it into its own fast lane on that finding. **Lane O's scope is +deliberately coarse:** animate the existing `is_running` signal on the in-view +CLI surface + `CliMainPane`, and distinguish working-vs-idle copy. Nothing more. + +**This document does not re-spec Lane O.** It owns the *finer-grained remainder* +Lane O explicitly is not doing (§5): per-subagent activity indication, +per-message streaming granularity, an "actively emitting" signal derived from +patch cadence, and — the real composition question — **how the processing +indicator relates to the subagent tabs** (does a tab pulse while its subagent +works?). Facet 1 here is: *coarse fix in Lane O; here is the complete +activity-legibility model it is the first step of.* + +Facets 2–3 are a genuine feature with real product decisions: what counts as +"active", whether finished-subagent tabs linger, how many tabs before overflow, +whether the indicator names the subagent, and how identity unifies. Those +decisions (§10) are the point of this document. + +## 2. Relationship to the seamless-sync design (don't contradict — extend) + +| Seamless-sync (#38) established | This doc extends it by | +| --- | --- | +| §2.5 `cli_activity` poller derives `running`/`attention`/`idle` from tmux pane behavior and rides the SQLite update-hook → workspace SSE patch | Facet 1: surface `running` as an **in-view animated** cue, not just a sidebar bucket + a static badge (the coarse version of this = **Lane O**, §1.1; this doc adds streaming + per-agent granularity) | +| §5 ingests the main transcript but **excludes `agent-*` sidechain files in v1**; sidechain records are skipped | Facet 2: this is exactly the deferred subagent seam — the doc specifies how to surface it without contradicting the v1 exclusion (it becomes v-next) | +| §5 transport: transactional outbox → session-keyed revisioned snapshot+live stream; one merged session feed | Facets 1–2 ride this same stream — no new transport is proposed | +| §6 presence: header chips "CLI attached" / "UI viewing (n)"; imported turns labeled "via CLI" from `origin` | Facet 3: generalize `origin` + the executor `AgentIcon` + `subagent_type` into one identity model that presence and tabs both read | +| §3 T7: claude flushes **per step, mid-turn** (5 appends over a ~16 s run) | Facets 1–2 realtime feasibility rests on this — subagent steps and "still working" are observable at step granularity, not just turn-end | + +Nothing here overrides a seamless-sync decision. Where they touch, this doc +cites the section. The subagent work presumes the seamless-sync ingester exists +(it landed: PR #39 `claude_transcript_ingest`, PR #41 concurrency). + +## 3. Background — the three legibility surfaces that exist today + +### 3.1 Facet 1: processing indicators today (rich for executor, invisible for CLI) + +**Executor-driven runs have good, animated cues.** Run state comes from +execution-process status: + +- `packages/ui/src/components/SessionChatBox.tsx:319` — `isRunning = status === + 'running' || status === 'queued'`; `:321-325` gates `showRunningAnimation`. +- `SessionChatBox.tsx:792-796` — renders `` plus + the in-progress todo text (a "thinking" surrogate); passed down at `:758` + `isRunning={showRunningAnimation}` → `packages/ui/src/components/ChatBoxBase.tsx:85` + adds the `chat-box-running` class. +- `packages/ui/src/components/WorkspaceSummary.tsx:142-150` — animated + `` (three pulsing dots, `packages/ui/src/components/RunningDots.tsx:1-9`, + `animate-running-dot-*`) when `isRunning`. +- `packages/ui/src/components/ProcessListItem.tsx:97,112-113` — `` + per running process. +- `packages/web-core/src/pages/workspaces/CliMainPane.tsx:95-108` — a + `CircleNotchIcon animate-spin` **but gated on executor processes only** + (`executorRunning ? … : codingAgentRunning`), not on tmux-native activity. + +**CLI ("UIX") mode has the signal but renders it as static presence.** The only +dedicated CLI cue is a badge: + +- `packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx:1038-1051` + — a `` with a **static** `` (no + animation) and text `t('conversation.cliSessionActive')` = + **"CLI session active"** (`packages/web-core/src/i18n/locales/en/common.json:77`). + Presence wording, not "working". +- Truthiness: `packages/web-core/src/pages/workspaces/WorkspacesLayout.tsx:72-75` + — `cliSessionActive = isRunning && !isExecutorRunning`, from + `packages/web-core/src/shared/hooks/useWorkspaces.ts:75-76` (`ws.is_running`, + `ws.is_executor_running`). +- **`is_running` genuinely includes live CLI output activity:** + `crates/db/src/models/workspace.rs:586-592` — `… OR EXISTS (SELECT 1 FROM + workspace_cli_activity ca WHERE ca.workspace_id = w.id AND ca.state = + 'running')`, commented *"CLI-mode tmux claude actively producing output."* + +So the badge is wired to the real activity state — it just doesn't *look* like +activity. The same `is_running` drives the sidebar's animated `RunningDots` +(`packages/ui/src/components/WorkspacesSidebar.tsx:153,346,463`), so CLI activity +*does* pulse — but only in the peripheral sidebar bucket, not where a UIX-mode +user is looking (the terminal pane or the conversation view). + +**The stream hook has no "actively emitting" signal.** +`packages/web-core/src/shared/hooks/useJsonPatchWsStream.ts` receives patch +frames (`:123-144`), a `Ready` frame (`:147-151`), and a terminal `{finished:true}` +frame (`:155-160`), but exposes only `isConnected` / `isInitialized` / `error` +(`:244-249`). The CLI-native chat feed +(`packages/web-core/src/features/workspace-chat/model/hooks/useSessionNativeFeed.ts:63,80-81`) +surfaces `isConnected` and `isLoading`. Nothing means "a patch just arrived / the +agent is emitting right now", even though the hook is the obvious place to derive +it (a debounced "last patch < N ms ago"). + +### 3.2 Facet 2: subagents today (inline call+result, no realtime, no tabs) + +Subagents already have exactly one surface — an inline collapsible chat row for +the `Task` tool: + +- `packages/ui/src/components/ChatSubagentEntry.tsx` — header shows the subagent + type (`:92-96` `formattedType`, capitalized, uppercase) + description + (`:149-159`); status icon is success `CheckCircle` / error `XCircle` / pending + `CircleNotchIcon animate-spin` (`:67-77`); expands to show the subagent's final + output (`:170-180`). Glyph is `CpuIcon`. +- Fed from the executor stream's `task_create` tool call: + `packages/web-core/src/features/workspace-chat/ui/DisplayConversationEntry.tsx:225-229` + passes `subagentType={action_type.subagent_type}`. + +This is **call + result**, not realtime internals: one row that spins while +pending and shows a result when done. It has no notion of the subagent's live +steps, and it is a chat row, never a tab. + +The CLI/native path shows **nothing** for subagents. Sidechain content is +suppressed at two layers (§4.1). So "subagents spawned in a CLI session" — facet +2's literal ask — is currently invisible end to end. + +### 3.3 Facet 3: identity today (three disconnected axes) + +Agent identity is expressed three unrelated ways, none of them a tab, none of +them a shared model: + +1. **Subagent name** = `subagent_type` (nullable) on a `task_create` action — + `shared/types.ts:854` (`ActionType` variant `{ action: "task_create", + description, subagent_type, result }`); row family + `packages/web-core/src/features/workspace-chat/model/conversation-row-model.ts:36,151` + (`'subagent'`). +2. **Executor badge** = `AgentIcon` keyed on the *executor* (claude/codex/…), + `packages/web-core/src/shared/components/AgentIcon.tsx`, rendered next to + session titles (`SessionChatBox.tsx:766,898,935`; + `SessionChatBoxContainer.tsx:1165-1166,1220-1221`). This encodes *which tool*, + not main-vs-subagent. +3. **Turn source** = `NativeFeedOrigin = "cli" | "app" | "executor"` — + `shared/types.ts:367`, carried on every `NativeFeedEntry.origin` (`:371`). The + nearest existing axis to "who produced this", but it is transport/source, not + agent hierarchy. + +The quarantine panel — **already on `origin/main`** — is the concrete instance +this doc generalizes (not re-specs): +`UnassignedCliSession` (`shared/types.ts:385`) whose only human label is +`first_prompt_snippet` (no agent name), assigned via +`AssignNativeCliSessionRequest` (`:387`); UI +`packages/web-core/src/features/workspace-chat/ui/UnassignedCliSessions.tsx:47-118`; +hook `…/model/hooks/useUnassignedCliSessions.ts:16-37`; backend +`crates/db/src/models/claude_session_link.rs`, `…/cli_native_file.rs`, route +`crates/server/src/routes/native_transcripts.rs`. (`chief/bc-cli-session-labeling` +has zero commits beyond `origin/main` at time of writing — the panel is present, +so this doc treats it as the baseline to unify, not pending work to duplicate.) + +**There is no `entrypoint` field and no main-vs-subagent boolean anywhere in the +domain model** (the only `entrypoint` hits are unrelated stack-trace fixtures in +`crates/executors/src/executors/claude.rs:3274-3373`). That absence is the core +facet-3 gap. + +## 4. Signals available (grounded) — what the realtime model can key off + +### 4.1 The native transcript envelope and the two sidechain suppressors + +The ingester adapts each native JSONL line through +`crates/executors/src/executors/claude/native.rs`. The envelope it exposes +(`NativeClaudeEnvelopeMetadata`, `native.rs:26-37`) is: + +``` +claude_session_id, uuid, parent_uuid, timestamp, version, +git_branch, kind, leaf_uuid, is_sidechain +``` + +The wire struct (`native.rs:105-124`) additionally parses `is_synthetic` +(`:120-121`) and `is_replay` (`:122-123`). **There is no `agentId` and no +`entrypoint`.** A subagent is signalled only by `is_sidechain: true` +(`native.rs:114-115`), and a record so flagged is short-circuited to +`Skip(Sidechain)` with `event: None` (`native.rs:151-157`). + +Sidechain (subagent) content is suppressed at **two** layers today: + +1. **File level** — the directory scan skips `agent-*.jsonl` entirely: + `crates/services/src/services/claude_transcript_ingest.rs:635-638` + (`file_name.starts_with("agent-")` → `continue`). Separate per-subagent + transcript files are never even read. +2. **Record level** — inline `isSidechain: true` records in the *main* + transcript are ingested but marked non-renderable: + `claude_transcript_ingest.rs:831-842` maps `Skip(Sidechain)` → + `CliNativeRecordDisposition::Sidechain`; the row is still persisted raw + (`:847-861`, a `NewCliNativeRecord { uuid, parent_uuid, kind, disposition, … + }`). So the data is on disk in the DB — merely hidden. + +**Consequence for the design:** the record-level sidechain data is already +captured (surfacing it is a render/projection change, not new ingestion). The +file-level `agent-*.jsonl` content is *not* captured — surfacing file-based +subagents requires the scan to stop excluding them (still read-only; §5.2 of #38 +watches only the slug dir, which would need to include `agent-*`). + +### 4.2 Per-subagent identity — the gap and the workable key + +Because there is no `agentId` field, per-subagent identity must be **derived**: + +- **File-based subagents:** identity = the `agent-.jsonl` filename (the `` + is claude-internal but stable per subagent within a run). +- **Inline sidechain records:** identity = the **root `parent_uuid` of a + sidechain sub-DAG** — the first `is_sidechain` record whose `parent_uuid` points + at a non-sidechain (main-agent) record is a spawn; its `uuid` roots that + subagent's chain; descendants share the lineage. +- **Executor path (richest):** the `task_create` action already carries + `subagent_type` + `description` (`shared/types.ts:854`) — a real human name and + task. Where an executor Task call and a native sidechain sub-DAG describe the + same subagent, the Task call is the naming source and the sidechain records are + its live internals. + +This derivation is the heart of facet 3 (§7): a single `AgentRef` computed from +whichever signals are present. + +### 4.3 Spawn / active / inactive — a concrete rule + +- **Spawn detected** = a new `agent-.jsonl` appears in the watched slug dir, + **or** the first `is_sidechain` record with a new root `parent_uuid` is + appended, **or** (executor path) a `task_create` tool_use is streamed. +- **ACTIVE** = the subagent has appended a record within a short recency window + and has not terminated. Reuse the seamless-sync cadence intuition (T7: + per-step, seconds apart) and the existing `ACTIVE_WINDOW_SECS = 5` + (`crates/local-deployment/src/cli_activity.rs:62-65`) as the starting constant. + `is_synthetic` / `is_replay` records (`native.rs:120-123`) **do not** count as + activity (backfill/replay must not light up a tab). +- **INACTIVE (done)** = the subagent's terminal signal is observed. There is a + path asymmetry to state honestly (confirmed: the native path has **no** + `stop_reason` — that field exists only in the executor stream types, + `crates/executors/src/executors/claude.rs:2876,2977`): + - *Executor path:* the `task_create` action's `result` becomes non-null / its + tool status flips to `success|failed` — exactly what `ChatSubagentEntry` + already keys on (`ChatSubagentEntry.tsx:59-77`). + - *Native path:* completion is **inferred** — the parent (main-agent, non- + sidechain) transcript resumes appending after the subagent's last sidechain + record (the Task returned), **or** the sub-DAG has been quiet past an + idle window while the parent is still running. +- **Proposed unified rule:** a subagent is ACTIVE from its spawn record until the + first of {Task `result` observed, parent resumes past its last sidechain + record, quiet > `SUBAGENT_IDLE_SECS` (propose 10 s)}. Everything else is + INACTIVE. The exact windows are an open question (§10 Q7). + +### 4.4 Facet-1 signals — already end-to-end for the fix + +- **Executor:** `status ∈ {running, queued}` (`SessionChatBox.tsx:319`) — already + animated. +- **CLI/native (coarse):** `workspace_cli_activity.state = 'running'` + (`crates/db/src/models/workspace_cli_activity.rs:6-38`; transition function + `cli_activity.rs:614-652`; 2 s poll `:42`, 5 s window `:62-65`) already flows to + the frontend via `is_running` (`workspace.rs:586-592`) and `cliSessionActive` + (`WorkspacesLayout.tsx:72-75`). **The signal exists; only its presentation is + missing.** +- **CLI/native (fine):** a derived "actively emitting" boolean from patch-arrival + cadence on `useJsonPatchWsStream.ts` / `useSessionNativeFeed.ts` (§3.1) — new, + but small and local to the hook. + +## 5. Design — Facet 1: the processing indicator (Lane O coarse fix + the finer model) + +**Split of ownership:** + +- **Lane O (`chief/bc-cli-working-indicator`), shipping now — COARSE:** promote + the existing CLI `running` signal from the static "session exists" badge + (`ConversationListContainer.tsx:1038-1051`) into an in-view **animated + "working" indicator**, and mirror it on `CliMainPane`; working-vs-idle copy. + Backend-free (§5.4). This spike does not re-spec that work. +- **This doc — FINER (assume Lane O does none of the below):** (a) an "actively + emitting / streaming" state derived from patch-arrival cadence in the stream + hooks; (b) **per-agent** activity — the same signal computed for each subagent, + not just the session as a whole; (c) the composition rule with facet 2 — a + subagent tab pulses while that subagent is working, and the header indicator can + name the working subagent. Unify executor + CLI + per-subagent into one + `AgentActivity` view-model so every surface renders the same component.** + +### 5.1 One view-model, three states + +``` +AgentActivity = Idle | Working | AwaitingInput + Working: executor status∈{running,queued} OR cli_activity.state='running' + OR a native/normalized patch arrived < STREAM_IDLE_MS ago + AwaitingInput: cli_activity.state='attention' (run went quiet unattended) + Idle: otherwise +``` + +The `cli_activity.state='running'` term is exactly Lane O's coarse signal; the +patch-cadence term and the per-agent computation are this doc's additions. The +`Working` state optionally carries a **subject** (§6 ties this to facet 2): "main +agent" vs a named subagent. The indicator distinguishes them only once facet 2 +ships (§10 Q5/Q9); until then `Working` is unqualified — which is precisely what +Lane O ships. + +### 5.2 Where it renders (in-view, not peripheral) + +- **Lane O (coarse) owns these:** replace the static + `ConversationListContainer.tsx:1038-1051` badge's non-animated `CircleIcon` + with an animated "working" component + reworded copy; mirror the executor + spinner (`CliMainPane.tsx:95-108`) for tmux-native `running` so the + terminal-first surface finally shows non-executor activity; keep the sidebar + `RunningDots` (already correct). +- **This doc adds on top:** a compact **streaming pill** in the conversation + header driven by the patch-cadence term (so "working" sharpens to "streaming…" + when content is actively landing), and the **per-agent** rendering — each + subagent's tab carries its own activity dot (§6.4), and the header indicator can + name the working subagent rather than showing one session-wide state. + +### 5.3 ASCII mockups — facet 1 + +Today (CLI mode), the only cue is a static dot that reads as presence: + +``` +┌ Conversation ──────────────────────────────────────────────┐ +│ ● CLI session active [via CLI] │ ← static dot, +│ │ "session +│ user: refactor the auth module │ exists" not +│ assistant: Sure — I'll start by … │ "working" +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Type a follow-up… │ │ ← composer: +│ └───────────────────────────────────────────────────────┘ │ no running +└─────────────────────────────────────────────────────────────┘ state +``` + +Proposed — an animated, labeled working state, in view: + +``` +┌ Conversation ──────────────────────────────────────────────┐ +│ ◍ Working · streaming… [via CLI] │ ← animated; +│ │ "streaming" +│ user: refactor the auth module │ when patches +│ assistant: Sure — I'll start by reading auth/… │ arriving +│ ⋯ running tool: Grep("verifyToken") │ ← live step +│ │ (T7 cadence) +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Type a follow-up… ◍ agent busy │ │ ← composer +│ └───────────────────────────────────────────────────────┘ │ mirrors state +└─────────────────────────────────────────────────────────────┘ +``` + +Mobile: the same pill sits in the CLI pane's slim header (the PR #26 header, +`CliMainPane.tsx:66-93`), icon-only when space is tight (◍) with an `aria-label`. + +### 5.4 Relationship to Lane O — what it ships, what this doc keeps + +The reason facet 1's coarse half could be pulled into its own lane: the backend +signal, its SSE transport, and the `is_running`/`cliSessionActive` plumbing all +exist (§4.4). Lane O's fix is therefore purely frontend — (1) swap the static +badge for an animated "working" component and reword the string; (2) mirror it on +`CliMainPane`. No schema, no new backend service, no type changes. + +What Lane O does **not** do, and this doc keeps (assume none of it lands in Lane +O — it may attempt the patch-cadence boolean only as a cheap stretch): + +- The **"actively emitting / streaming"** boolean derived from patch-arrival + cadence on `useJsonPatchWsStream.ts` / `useSessionNativeFeed.ts` (§3.1) — turns + a binary working/idle into working → streaming → idle. +- **Per-subagent** activity (§6.4) — Lane O renders one session-wide state; the + finer model computes activity per `AgentRef` so each tab pulses independently. +- The **composition rule** with facet 2 (§6.2, §10 Q9) — a tab pulses while its + subagent works, and the header indicator names the working subagent. + +So Lane O is Phase A's coarse first step; §9 Phase A below is scoped to only the +finer remainder. + +## 6. Design — Facet 2: realtime subagent tabs + inactive drawer + +**Recommendation: a session-scoped "agent activity strip" that lives inside the +conversation/CLI pane (never the global nav). ACTIVE subagents render as tabs; +INACTIVE ones collapse behind a single "N done ▾" button that opens a drawer. +The strip is bounded by construction and degrades to the existing inline +`ChatSubagentEntry` rows on mobile.** + +### 6.1 Placement — a local strip, not the global tab bar + +The only horizontal tab strip today is the **global mobile nav** +(`Navbar.tsx:97-114,251-294`), a static 7-tab set already at its width ceiling +(§8). Desktop has no tab strip at all (`Navbar.tsx:363-418` renders icon-only +toggles + `RightSidebar.tsx`). Subagent tabs therefore **cannot** join the global +nav — they get their own **session-scoped strip** rendered at the top of the +conversation pane (and mirrored in the CLI pane header), owned by the active +session, disappearing when no subagents are active. + +This also matches the data's scope: subagents belong to one conversation, not to +the whole app. + +### 6.2 Active-as-tabs, inactive-behind-a-button (the owner's nuance) + +- The strip shows a tab **only** for subagents currently `ACTIVE` (§4.3). + Typical count is 0–2; parallel fan-out can spike higher. +- A finished subagent's tab **collapses into a trailing "N done ▾" button** + (whether it lingers briefly first is §10 Q3). The button opens a drawer listing + all inactive subagents for the session, each expandable to its transcript / + result (reusing `ChatSubagentEntry`'s expand). +- A leading "main" chip always anchors the strip (facet 3), so the user can + always return focus to the main agent. + +### 6.3 Bounding the active-tab count + +Active subagents are few and short-lived, so the strip is self-bounding in +practice. For the pathological fan-out, overflow is explicit rather than +infinite scroll: show up to `MAX_ACTIVE_TABS` (propose 4) tabs; beyond that, +extra active subagents also fold into the "N done ▾" button relabeled "N more ▾" +(count of not-shown active + inactive). This reuses one overflow affordance for +both cases and never lets the strip grow unbounded — directly addressing the fact +that **no overflow menu exists today** (§8). + +### 6.4 ASCII mockups — facet 2 + +Desktop, two subagents running under the main agent: + +``` +┌ Conversation ──────────────────────────────────────────────────────┐ +│ ⌂ Main ·◍ │ ⌕ Explore ·◍ │ ⚙ code-review ·◍ │ 2 done ▾ │ ← strip: +│─────────────────────────────────────────────────────────────────────│ active +│ │ = tabs, +│ [Explore] scanning packages/… (14 files) │ inactive +│ ⋯ running tool: Grep("MOBILE_TABS") │ = button +│ │ +└─────────────────────────────────────────────────────────────────────┘ + ◍ = animated working dot per tab (facet 1 signal, per-agent) +``` + +The "2 done ▾" drawer: + +``` + ┌ Inactive subagents (this session) ─────────────┐ + │ ✓ Explore "map tab strip" 1m ago ▸ │ ← expand → result + │ ✓ general "find PR #42" 3m ago ▸ │ + │ ✗ code-review "review diff" (failed) 4m ago ▸│ + └─────────────────────────────────────────────────┘ +``` + +Mobile — the global 7-tab nav is untouched; the strip degrades to a single +collapsible summary line inside the conversation pane, and the *inline* +`ChatSubagentEntry` rows (which already exist) remain the primary reading +surface: + +``` +┌ Conversation (mobile) ───────────────────┐ +│ ⌂ Main ·◍ 2 active · 3 done ▾ │ ← one line; tap to expand a sheet +│───────────────────────────────────────────│ listing active + inactive. +│ [Explore] scanning packages/… │ No horizontal tab scroll on +│ ⋯ Grep("MOBILE_TABS") │ mobile — avoids the §8 crowding. +│ │ +│ ▸ code-review (running) │ ← inline rows unchanged +└───────────────────────────────────────────┘ +``` + +### 6.5 Data flow (read-only projection over existing rows) + +- **Executor subagents:** already present as `task_create` actions; the strip is a + new *projection/aggregation* over the same conversation rows the inline entries + use (`conversation-row-model.ts:151`) — group by subagent, compute active/done + from status. No new ingestion. +- **Native (CLI) subagents:** requires un-hiding the record-level sidechain rows + (already persisted, `claude_transcript_ingest.rs:847-861`) via a new render + projection, and — for file-based subagents — letting the scan read `agent-*.jsonl` + (§4.1). Both are read-only. This is the piece #38 §5 explicitly deferred; it + becomes the first implementation step of facet 2. +- **Transport:** the seamless-sync merged session feed (#38 §5) already carries + these rows; the strip subscribes to the same stream. No new SSE channel. + +## 7. Design — Facet 3: one generalized agent-identity model + +**Recommendation: introduce a single derived `AgentRef` that unifies the three +disconnected axes (subagent_type, executor `AgentIcon`, `NativeFeedOrigin`) plus +a main-vs-subagent role, and render it consistently across turns, the subagent +strip/tabs, and presence chips. This is a view-model unification, not a schema +migration.** + +### 7.1 The `AgentRef` shape (conceptual — no type change proposed here) + +``` +AgentRef { + role: 'main' | 'subagent' # derived: is_sidechain / task_create vs not + name: string # subagent_type ▸ agent- ▸ "Main" + executor: Executor # existing AgentIcon key (claude/codex/…) + origin: 'cli' | 'app' | 'executor' # existing NativeFeedOrigin + activity: Idle | Working | AwaitingInput # facet 1 view-model, per agent +} +``` + +- `role` fills the current void — no `entrypoint`/main-vs-subagent flag exists + today (§3.3); it is derived from `is_sidechain` (native) or `task_create` + (executor). +- `name` prefers the human `subagent_type` (`shared/types.ts:854`), falls back to + the `agent-` filename, then "Main". +- `executor` reuses `AgentIcon` (`AgentIcon.tsx`) unchanged — identity of *tool*. +- `origin` reuses `NativeFeedOrigin` (`shared/types.ts:367`) unchanged — identity + of *surface* ("via CLI" already planned in #38 §6). + +### 7.2 Where `AgentRef` renders (one model, many surfaces) + +- **Turn attribution:** each conversation entry shows `role`+`name` (generalizing + today's ad-hoc `subagent_type` row and the "via CLI" label into one badge). +- **Subagent tabs (facet 2):** each tab *is* an `AgentRef` with `role:'subagent'`; + the leading chip is the `role:'main'` ref. +- **Presence chips (#38 §6):** "CLI attached" / "UI viewing (n)" become + `AgentRef`-shaped, so the same component renders human presence and agent + presence consistently. +- **Quarantine panel (existing):** `UnassignedCliSession` gains a place to show a + derived name instead of only `first_prompt_snippet` — **without re-specing the + panel**; it simply consumes `AgentRef` when a binding exists. This is the + "generalize, don't re-spec" boundary the brief drew (§3.3). + +### 7.3 Explicit boundary vs lanes L/M + +This doc **does not** design the quarantine/labeling panel — that is on +`origin/main` and owned by the concurrent lanes. Facet 3 only defines the shared +identity view-model those surfaces (and facets 1–2) can all read, so labels stop +being invented three different ways. If lane M lands a first-class role/label +field, `AgentRef.role`/`name` bind to it instead of deriving — the model is +forward-compatible with that. + +## 8. The crowded-UI constraint (first-class) + +This is a hard constraint, not a footnote. The mobile nav already fought for +horizontal room and won by **dropping labels**: + +- 7 fixed tabs (`Navbar.tsx:97-104`: workspaces, chat, changes, logs, preview, + git, files), mapped at `:265-294` in a `flex … overflow-x-auto` row (`:251`). +- **PR #42** (commit `8f153c7c`, "add Files as a 7th mobile tab, active-only tab + labels") made only the active tab show its text label: + `Navbar.tsx:289` — `{tab.label}`. + This *replaced* the earlier width-driven `hidden min-[480px]:inline`; adding the + 7th tab overflowed that, so labels became active-state-driven. Accessible names + survive via `aria-label`/`aria-current` (`:273-276`) and active state also shows + in icon weight (`:287`). +- **There is no overflow menu, no "+N", no dropdown** — only horizontal scroll + (`overflow-x-auto`) and the active-only-label shrink. The tab set is static + (7 mobile / 6 remote — `NavbarContainer.tsx` filters `files` out on remote), so + nothing today collapses a *dynamic* tab count. (The generic multi-terminal + `TerminalPanel.tsx:14` likewise `tabs.map`s with no overflow handling.) +- Mobile is `max-width: 767` (`packages/web-core/src/shared/hooks/useIsMobile.ts:3`, + `matchMedia`); Tailwind `md:` (≥768) is its intentional complement + (documented `CliMainPane.tsx:68-69`). +- Footnote for implementers: `cn()` is bare `clsx` with **no** tailwind-merge + (`packages/ui/src/lib/cn.ts:3-6`) — `cn("hidden", isActive && "inline")` emits + both and `.hidden` wins; PR #42 hit exactly this. Any conditional + show/hide in the new strip must use a ternary, not two conflicting utilities. + +**How the design honors it:** + +1. **Subagent tabs never enter the global nav.** They are session-scoped and live + in the conversation/CLI pane (§6.1). The 7-tab ceiling is untouched. +2. **Bounded active count + one overflow affordance.** `MAX_ACTIVE_TABS` + the + "N more ▾" button (§6.3) supplies the collapse behavior the codebase lacks, + scoped to the new strip only. +3. **Mobile degrades to a single summary line + existing inline rows** (§6.4) — no + horizontal tab scroll is introduced on mobile at all. Facet 1's indicator is a + single pill that goes icon-only under pressure, exactly like the nav's + active-only-label trick. + +## 9. Phasing & risk + +**Phase A₀ — coarse working indicator = Lane O (`chief/bc-cli-working-indicator`), +in progress, not owned here.** Animate + reword the CLI activity badge; mirror on +`CliMainPane`. Listed only to place the ordering. + +**Phase A — Facet 1 finer remainder (S, risk: low).** The parts Lane O does not +do: patch-cadence "streaming" boolean in the stream hooks; per-agent activity +computation so the signal can attach to individual subagents (feeds Phase C). +No schema/type changes beyond an optional derived boolean. +*Verification:* in a CLI session the header sharpens from "working" to +"streaming…" while patches land and settles to idle within one debounce of the +last patch; executor runs unchanged; mobile shows the icon-only pill; the +per-agent signal lights the right subagent (checked once Phase C lands). + +**Phase B — Facet 3 `AgentRef` view-model (S–M, risk: low).** Derive `AgentRef`; +render unified turn attribution + presence chips. No migration. +*Verification:* a mixed conversation (executor turn, CLI turn, a subagent) labels +each turn's role/name/origin consistently; quarantine panel shows a derived name +where a binding exists. + +**Phase C — Facet 2 subagent strip (M, risk: medium — un-hides sidechain data).** +Render projection over `task_create` + un-hidden native sidechain rows; active +tabs + inactive drawer + overflow; wire per-tab activity to facet 1. +*Verification:* spawn 3 subagents in a CLI run → up to `MAX_ACTIVE_TABS` tabs +appear live (step cadence, T7), finished ones move to the drawer; `is_synthetic` +backfill never lights a tab; mobile shows the summary line, global nav unaffected; +replay a transcript with an `agent-*.jsonl` fan-out → strip renders, nothing +crashes. + +Ordering rationale: A is the urgent, isolated win; B is the cheap unifier C +depends on for labels; C is the largest and rides on both. + +## 10. Open questions — decisions for the owner (the point of this doc) + +Each with a recommendation; nothing proceeds until you approve, item by item. + +1. **Facet-1 coarse fix — DECIDED (Lane O).** The coarse "is the agent working" + indicator is already its own lane (`chief/bc-cli-working-indicator`), scoped to + animating the existing `is_running` signal in view + on `CliMainPane`. The open + remainder for you: **is the finer "streaming…" state (patch-cadence) worth + building at all, or is coarse working/idle enough?** Rec: **build the finer + state** — it is small (§5.4) and it is what makes mid-turn tool steps (T7) feel + live rather than frozen; but it is genuinely optional and can wait behind + facets 2–3. +2. **Where do subagent tabs live?** Rec: **session-scoped strip in the + conversation/CLI pane, never the global nav** (§6.1). Approve the surface + before C starts. +3. **Do finished-subagent tabs linger?** Options: (a) collapse to the drawer + immediately on completion; (b) linger `N` seconds then collapse; (c) stay a tab + until the whole turn ends. Rec: **(b), ~5 s linger** so a fast subagent is still + noticed, then collapse — keeps the strip honest to "active". +4. **How many active tabs before overflow?** Rec: **`MAX_ACTIVE_TABS = 4`**, extras + fold into the "N more ▾" button (§6.3). Tune to taste. +5. **How does the processing indicator compose with the subagent tabs?** Two + linked sub-decisions: (a) **Does each tab pulse on its own** while that + subagent is working? Rec: **yes** — per-agent activity (§5.4, §6.4) makes each + tab carry its own dot, which is the whole point of "tracking in realtime". (b) + **Does the header indicator name the working subagent** ("Explore working…") + vs staying unqualified like Lane O's coarse state? Rec: **name it once facet 2 + ships** (§5.1, §6.2); unqualified until then. (Alternative for both: one + session-wide state only — simpler, but loses the per-subagent legibility the + owner asked for.) +6. **File-based subagents (`agent-*.jsonl`) in scope, or inline-sidechain only?** + Un-hiding inline records is nearly free (already persisted); reading `agent-*` + files reverses a deliberate #38 v1 exclusion and widens the watch set. Rec: + **inline sidechain records first (Phase C), `agent-*` files as a follow-up** once + the strip proves out. +7. **Active/idle windows for subagents.** Rec: reuse `ACTIVE_WINDOW_SECS = 5` for + "still working" and propose `SUBAGENT_IDLE_SECS = 10` for "gone quiet"; confirm + or tune against a live fan-out (§4.3). +8. **Should the indicator/tabs also cover executor (non-CLI) runs, or CLI only?** + The owner framed it as "UIX mode", but the `AgentRef`/activity model is + uniform. Rec: **uniform** — one component for both; costs nothing extra and + keeps executor and CLI legibility consistent. +9. **Naming an unnamed subagent.** Native sidechains have no `subagent_type`; + only `agent-`. Rec: show a **generic "Subagent" + short id**, upgraded to the + real name if an executor `task_create` correlates. (Alternative: hide unnamed + ones from tabs, show only in the drawer.) +10. **Presence unification depth.** Rec: make #38's "CLI attached"/"UI viewing (n)" + chips `AgentRef`-shaped in Phase B so human and agent presence share one + component (§7.2). (Alternative: keep them separate; less consistent.) +11. **Mobile subagent affordance.** Rec: **summary line + expandable sheet + keep + inline rows** (§6.4); explicitly **no** horizontal subagent-tab scroll on + mobile (§8). Confirm you're happy trading tab fidelity for the crowding + constraint on small screens. + +## 11. Non-goals / deferred (scope honesty) + +- **No implementation.** This lane ships only this document. +- **The coarse working indicator itself** — owned by **Lane O** + (`chief/bc-cli-working-indicator`), not this doc. Facet 1 here is the finer + remainder + the model it fits into (§1.1, §5). +- **Token/keystroke-level streaming.** Realtime is step/block-level (#38 C5); the + indicator means "working", not a live token stream. +- **Re-specing the quarantine/session-labeling panel** (lanes L/M own it, already + on main). Facet 3 only defines the shared `AgentRef` those surfaces can read. +- **A first-class `entrypoint`/role column.** The design *derives* role; adding a + persisted field is a separate decision (forward-compatible, §7.3). +- **Non-claude subagents** (codex/gemini fan-out) — their stores differ; a future + spike, mirroring #38's non-claude deferral. +- **Reversing #38's `agent-*.jsonl` v1 exclusion by default** — gated behind Q6; + not assumed. +- **Desktop tab-strip parity for the global nav.** Desktop keeps its icon + toggles + `RightSidebar`; the subagent strip is the only new tabbed surface, and + it is session-scoped. +- **Notifications** ("a subagent finished" toast/badge) — reuse #38's + attention plumbing later if wanted; not designed here.