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..3e6151bcf0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-agent-activity-legibility-design.md @@ -0,0 +1,758 @@ +# Agent-Activity Legibility — Design Spike + +- **Date:** 2026-07-23 +- **Branch:** `chief/bc-agent-activity-design` (chief run, lane N) +- **Status:** Facet 2 (realtime subagent strip) **DECIDED & implemented for the executor / UI-driven path** — Lane P (`chief/bc-subagent-tabs`). Facets 1 & 3, and the native/CLI-sidechain path of facet 2, remain design-only. See **§0** for the owner decisions that override the recommendations below where they conflict. +- **Scope:** local deployments, claude executor + CLI/native sessions (see Non-goals). The *implemented* slice is **executor / UI-driven subagents only** — native/CLI sessions surface their own subagents in the terminal and are out of scope here (§0). +- **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`.** + +## 0. Decision & as-built (Lane P, 2026-07-23) + +Two owner decisions were made after this spike and **override the recommendations +below where they conflict**: + +**Decision 1 — EXECUTOR / UI-DRIVEN AGENTS ONLY (not CLI).** Verbatim: *"tabs is +only for non-cli, so for uix. cli already handles these sessions on it's own no +worries."* A real `claude` in a terminal already surfaces its own subagents, so +the CLI/native-sidechain path of facet 2 (§4.1 un-hiding, §6.5 "Native (CLI) +subagents", §10 Q6) is **out of scope** for the implementation. This removes the +design's riskiest piece (reversing #38's `agent-*.jsonl` exclusion / un-hiding +sidechain records). The strip is a pure **read-only projection over the executor +`task_create` conversation rows** the inline `ChatSubagentEntry` already consumes +— no new ingestion, no schema, no new stream. + +**Decision 2 — PER-TAB ACTIVITY.** Each active subagent tab carries its own +animated working indicator (reuses `RunningDots`), so the owner sees which +subagent is busy vs done at a glance — not one session-wide state (resolves §10 +Q5a → yes). + +Resolutions to §10 for the shipped facet-2 slice: **Q3** → (b) ~5 s linger +(`FINISHED_LINGER_MS = 5000`) then collapse to the drawer; **Q4** → +`MAX_ACTIVE_TABS = 4`, extras fold into the trailing "N more ▾" button; **Q5** → +(a) per-tab pulse YES, (b) header naming the working subagent deferred with facet +1; **Q8** → executor path implemented now, native/CLI deferred (Decision 1); +**Q9** → generic "Subagent" fallback when `subagent_type` is absent. + +### As-built (Lane P) + +- **Projection (pure, unit-tested):** + `packages/web-core/src/features/workspace-chat/model/subagent-strip-model.ts` + — `selectSubagents(entries)` maps `task_create` tool_use rows to descriptors + (phase: active = `created`/`pending_approval`, done = `success`, error = + `failed`/`denied`/`timed_out`); `partitionStrip(...)` does the active-first tab + budget, ~5 s linger, and overflow. Tests in `subagent-strip-model.test.ts` and + `subagent-strip-partition.test.ts`. +- **Hook:** `.../model/hooks/useSubagentStrip.ts` — tracks completion `doneAt` + per key and schedules a single absolute-deadline timer for the linger; reads + the live session-scoped `useEntries()` stream (no new subscription). +- **UI:** `.../ui/subagent-strip/{SubagentStrip,SubagentTab,SubagentDrawer}.tsx` + — leading Main chip (`AgentIcon`), active tabs with per-tab `RunningDots`, + done/error icons mirrored from `ChatSubagentEntry`, a Radix-`Popover` "N done ▾" + / "N more ▾" drawer reusing `ChatSubagentEntry`, and a mobile summary line + + expandable sheet (no horizontal tab scroll). +- **Mount:** above `` in + `.../pages/workspaces/WorkspacesMainContainer.tsx` and `VSCodeWorkspacePage.tsx` + (inside the session-scoped `EntriesProvider`); the strip self-hides when there + are no subagents. The global 7-tab nav (§8) is untouched. +- **i18n:** `conversation.subagentStrip.*` across all 7 locales. + +### Still design-only after this PR (recommendations unchanged, not built) + +- Facet 1 "streaming…" patch-cadence pill (§5). +- Facet 3 full `AgentRef` unification across turns / presence / quarantine (§7) — + only the executor-slice identity (subagent_type ▸ icon, role main/subagent) is + realized in the strip. +- Native/CLI subagent surfacing (§4.1, §6.5 "Native (CLI) subagents", §10 Q6). + +## 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) + +- **[IMPLEMENTED — Lane P, §0] 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`, family + `'subagent'`) — one descriptor per `task_create` row, active/done computed from + its `ToolStatus`. No new ingestion. This is the shipped path. +- **[DEFERRED — out of scope, §0] Native (CLI) subagents:** would require 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. Owner Decision 1 puts this out of scope: a terminal `claude` already + shows its own subagents. Not built. +- **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. + +> **DECIDED (Lane P, see §0) for the shipped facet-2 slice:** **Q2** surface → +> session-scoped strip in the conversation pane (yes). **Q3** → (b) ~5 s linger. +> **Q4** → `MAX_ACTIVE_TABS = 4` + "N more ▾" overflow. **Q5** → (a) per-tab pulse +> yes; (b) header naming deferred with facet 1. **Q8** → executor path only for +> now (native/CLI out of scope per Decision 1). **Q9** → generic "Subagent" +> fallback. **Q6** (native `agent-*.jsonl` / sidechain un-hiding) is **not** in +> scope. Q1, Q7, Q10, Q11 remain open for the deferred facets. + +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. diff --git a/packages/ui/src/components/ChatSubagentEntry.tsx b/packages/ui/src/components/ChatSubagentEntry.tsx index 75d98fb5a6..356f6cec94 100644 --- a/packages/ui/src/components/ChatSubagentEntry.tsx +++ b/packages/ui/src/components/ChatSubagentEntry.tsx @@ -73,7 +73,9 @@ export function ChatSubagentEntry({ return ; } if (isPending) { - return ; + return ( + + ); } return null; }, [status]); diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useSubagentStrip.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useSubagentStrip.ts new file mode 100644 index 0000000000..6b2f04f849 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useSubagentStrip.ts @@ -0,0 +1,129 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; + +import { useEntries } from '../contexts/EntriesContext'; +import { + partitionStrip, + selectSubagents, + type StripPartition, + type SubagentDescriptor, +} from '../subagent-strip-model'; + +export const MAX_ACTIVE_TABS = 4; +export const FINISHED_LINGER_MS = 5_000; + +export type UseSubagentStripResult = StripPartition & { + hasAny: boolean; +}; + +export function useSubagentStrip(): UseSubagentStripResult { + const { entries } = useEntries(); + const descriptors = useMemo(() => selectSubagents(entries), [entries]); + const phasesByKeyRef = useRef>( + {} + ); + const doneAtByKeyRef = useRef>({}); + const [doneAtByKey, setDoneAtByKey] = useState>({}); + const [now, setNow] = useState(() => Date.now()); + + useLayoutEffect(() => { + const currentKeys = new Set( + descriptors.map((descriptor) => descriptor.key) + ); + const previousDoneAtByKey = doneAtByKeyRef.current; + let nextDoneAtByKey = previousDoneAtByKey; + let hasDoneAtChanges = false; + + const makeDoneAtMutable = () => { + if (!hasDoneAtChanges) { + nextDoneAtByKey = { ...previousDoneAtByKey }; + hasDoneAtChanges = true; + } + }; + + for (const key of Object.keys(previousDoneAtByKey)) { + if (!currentKeys.has(key)) { + makeDoneAtMutable(); + delete nextDoneAtByKey[key]; + } + } + + const nextPhasesByKey: Record = {}; + let transitionTime: number | null = null; + + for (const descriptor of descriptors) { + const previousPhase = phasesByKeyRef.current[descriptor.key]; + nextPhasesByKey[descriptor.key] = descriptor.phase; + + if ( + descriptor.phase !== 'active' && + previousPhase === 'active' && + nextDoneAtByKey[descriptor.key] === undefined + ) { + transitionTime ??= Date.now(); + makeDoneAtMutable(); + nextDoneAtByKey[descriptor.key] = transitionTime; + } + } + + phasesByKeyRef.current = nextPhasesByKey; + + if (hasDoneAtChanges) { + doneAtByKeyRef.current = nextDoneAtByKey; + setDoneAtByKey(nextDoneAtByKey); + } + if (transitionTime !== null) { + setNow(transitionTime); + } + }, [descriptors]); + + useEffect(() => { + let soonestExpiry: number | null = null; + + for (const descriptor of descriptors) { + if (descriptor.phase === 'active') continue; + + const doneAt = doneAtByKey[descriptor.key]; + if (doneAt === undefined || now - doneAt >= FINISHED_LINGER_MS) { + continue; + } + + const expiry = doneAt + FINISHED_LINGER_MS; + soonestExpiry = + soonestExpiry === null ? expiry : Math.min(soonestExpiry, expiry); + } + + if (soonestExpiry === null) return; + + let cancelled = false; + const timeoutId = setTimeout( + () => { + if (!cancelled) { + setNow(Date.now()); + } + }, + Math.max(0, soonestExpiry - Date.now()) + ); + + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; + }, [descriptors, doneAtByKey, now]); + + const partition = useMemo( + () => + partitionStrip({ + descriptors, + doneAtByKey, + now, + maxActiveTabs: MAX_ACTIVE_TABS, + lingerMs: FINISHED_LINGER_MS, + }), + [descriptors, doneAtByKey, now] + ); + + return { + ...partition, + hasAny: descriptors.length > 0, + }; +} diff --git a/packages/web-core/src/features/workspace-chat/model/subagent-strip-model.test.ts b/packages/web-core/src/features/workspace-chat/model/subagent-strip-model.test.ts new file mode 100644 index 0000000000..79a36170bb --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/subagent-strip-model.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; +import type { ToolResult, ToolStatus } from 'shared/types'; + +import type { PatchTypeWithKey } from '@/shared/hooks/useConversationHistory/types'; +import { selectSubagents } from './subagent-strip-model'; + +const markdownResult: ToolResult = { + type: { type: 'markdown' }, + value: 'Finished result', +}; + +function taskCreateEntry({ + key, + status, + name = 'researcher', + description = `Task ${key}`, + result = null, +}: { + key: string; + status: ToolStatus; + name?: string | null; + description?: string; + result?: ToolResult | null; +}): PatchTypeWithKey { + return { + type: 'NORMALIZED_ENTRY', + content: { + timestamp: null, + content: '', + entry_type: { + type: 'tool_use', + tool_name: 'Task', + action_type: { + action: 'task_create', + description, + subagent_type: name, + result, + }, + status, + }, + }, + patchKey: key, + executionProcessId: 'process-1', + }; +} + +describe('selectSubagents', () => { + it('returns an empty projection for no entries', () => { + expect(selectSubagents([])).toEqual([]); + }); + + it('projects a single active task_create entry', () => { + const status: ToolStatus = { status: 'created' }; + + expect( + selectSubagents([ + taskCreateEntry({ + key: 'task-1', + status, + description: 'Inspect the test suite', + }), + ]) + ).toEqual([ + { + key: 'task-1', + name: 'researcher', + description: 'Inspect the test suite', + phase: 'active', + result: null, + status, + }, + ]); + }); + + it('maps active, done, and error statuses in spawn order', () => { + const entries = [ + taskCreateEntry({ + key: 'active', + status: { + status: 'pending_approval', + approval_id: 'approval-1', + }, + }), + taskCreateEntry({ + key: 'done', + status: { status: 'success' }, + result: markdownResult, + }), + taskCreateEntry({ + key: 'error', + status: { status: 'timed_out' }, + }), + ]; + + expect( + selectSubagents(entries).map(({ key, phase }) => [key, phase]) + ).toEqual([ + ['active', 'active'], + ['done', 'done'], + ['error', 'error'], + ]); + expect(selectSubagents(entries)[1].result).toBe(markdownResult); + }); + + it('preserves a null subagent type for the localized view fallback', () => { + const [descriptor] = selectSubagents([ + taskCreateEntry({ + key: 'unnamed', + name: null, + status: { status: 'success' }, + }), + ]); + + expect(descriptor.name).toBeNull(); + }); + + it('ignores entries that are not task_create tool uses', () => { + const otherTool: PatchTypeWithKey = { + type: 'NORMALIZED_ENTRY', + content: { + timestamp: null, + content: '', + entry_type: { + type: 'tool_use', + tool_name: 'Read', + action_type: { action: 'file_read', path: 'README.md' }, + status: { status: 'success' }, + }, + }, + patchKey: 'read-1', + executionProcessId: 'process-1', + }; + const stdout: PatchTypeWithKey = { + type: 'STDOUT', + content: 'not a normalized entry', + patchKey: 'stdout-1', + executionProcessId: 'process-1', + }; + + expect(selectSubagents([otherTool, stdout])).toEqual([]); + }); +}); diff --git a/packages/web-core/src/features/workspace-chat/model/subagent-strip-model.ts b/packages/web-core/src/features/workspace-chat/model/subagent-strip-model.ts new file mode 100644 index 0000000000..1af8eb96d0 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/subagent-strip-model.ts @@ -0,0 +1,128 @@ +import type { ToolResult, ToolStatus } from 'shared/types'; + +import type { PatchTypeWithKey } from '@/shared/hooks/useConversationHistory/types'; + +export interface SubagentDescriptor { + key: string; + name: string | null; + description: string; + phase: 'active' | 'done' | 'error'; + result: ToolResult | null; + status: ToolStatus; +} + +export interface StripPartitionInput { + descriptors: SubagentDescriptor[]; + doneAtByKey: Record; + now: number; + maxActiveTabs: number; + lingerMs: number; +} + +export interface StripTab { + descriptor: SubagentDescriptor; + lingering: boolean; +} + +export interface StripPartition { + tabs: StripTab[]; + drawer: SubagentDescriptor[]; + overflowCount: number; + overflowLabelMode: 'more' | 'done'; + activeCount: number; + doneCount: number; +} + +function getSubagentPhase(status: ToolStatus): SubagentDescriptor['phase'] { + switch (status.status) { + case 'created': + case 'pending_approval': + return 'active'; + case 'success': + return 'done'; + case 'failed': + case 'denied': + case 'timed_out': + return 'error'; + default: { + const _exhaustive: never = status; + return _exhaustive; + } + } +} + +export function selectSubagents( + entries: PatchTypeWithKey[] +): SubagentDescriptor[] { + return entries.flatMap((entry) => { + if ( + entry.type !== 'NORMALIZED_ENTRY' || + entry.content.entry_type.type !== 'tool_use' || + entry.content.entry_type.action_type.action !== 'task_create' + ) { + return []; + } + + const { action_type: actionType, status } = entry.content.entry_type; + + return [ + { + key: entry.patchKey, + name: actionType.subagent_type, + description: actionType.description, + phase: getSubagentPhase(status), + result: actionType.result, + status, + }, + ]; + }); +} + +export function partitionStrip({ + descriptors, + doneAtByKey, + now, + maxActiveTabs, + lingerMs, +}: StripPartitionInput): StripPartition { + const active = descriptors.filter( + (descriptor) => descriptor.phase === 'active' + ); + const lingering = descriptors.filter((descriptor) => { + if (descriptor.phase === 'active') return false; + + const doneAt = doneAtByKey[descriptor.key]; + return doneAt !== undefined && now - doneAt < lingerMs; + }); + const settled = descriptors.filter((descriptor) => { + if (descriptor.phase === 'active') return false; + + const doneAt = doneAtByKey[descriptor.key]; + return doneAt === undefined || now - doneAt >= lingerMs; + }); + + const budget = Math.max(0, maxActiveTabs); + const shownActive = active.slice(0, budget); + const shownLingering = lingering.slice(0, budget - shownActive.length); + const hiddenActive = active.slice(shownActive.length); + const hiddenLingering = lingering.slice(shownLingering.length); + const drawer = [...hiddenActive, ...hiddenLingering, ...settled]; + + return { + tabs: [ + ...shownActive.map((descriptor) => ({ + descriptor, + lingering: false, + })), + ...shownLingering.map((descriptor) => ({ + descriptor, + lingering: true, + })), + ], + drawer, + overflowCount: drawer.length, + overflowLabelMode: hiddenActive.length > 0 ? 'more' : 'done', + activeCount: active.length, + doneCount: lingering.length + settled.length, + }; +} diff --git a/packages/web-core/src/features/workspace-chat/model/subagent-strip-partition.test.ts b/packages/web-core/src/features/workspace-chat/model/subagent-strip-partition.test.ts new file mode 100644 index 0000000000..6d45ca527d --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/subagent-strip-partition.test.ts @@ -0,0 +1,198 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest'; + +import { + partitionStrip, + type SubagentDescriptor, +} from './subagent-strip-model'; + +const MAX_ACTIVE_TABS = 4; +const FINISHED_LINGER_MS = 5_000; +const NOW = 10_000; + +function descriptor( + key: string, + phase: SubagentDescriptor['phase'] +): SubagentDescriptor { + const status = + phase === 'active' + ? ({ status: 'created' } as const) + : phase === 'done' + ? ({ status: 'success' } as const) + : ({ status: 'failed' } as const); + + return { + key, + name: key, + description: `Task ${key}`, + phase, + result: null, + status, + }; +} + +function partition( + descriptors: SubagentDescriptor[], + doneAtByKey: Record = {}, + maxActiveTabs = MAX_ACTIVE_TABS +) { + return partitionStrip({ + descriptors, + doneAtByKey, + now: NOW, + maxActiveTabs, + lingerMs: FINISHED_LINGER_MS, + }); +} + +function tabKeys(result: ReturnType) { + return result.tabs.map(({ descriptor: item }) => item.key); +} + +function drawerKeys(result: ReturnType) { + return result.drawer.map((item) => item.key); +} + +describe('partitionStrip', () => { + it('shows exactly four active tabs without overflow', () => { + const result = partition([ + descriptor('active-1', 'active'), + descriptor('active-2', 'active'), + descriptor('active-3', 'active'), + descriptor('active-4', 'active'), + ]); + + expect(tabKeys(result)).toEqual([ + 'active-1', + 'active-2', + 'active-3', + 'active-4', + ]); + expect(result.tabs.every((tab) => !tab.lingering)).toBe(true); + expect(result.drawer).toEqual([]); + expect(result.overflowCount).toBe(0); + expect(result.activeCount).toBe(4); + expect(result.doneCount).toBe(0); + }); + + it('folds a fifth active task into more overflow', () => { + const result = partition([ + descriptor('active-1', 'active'), + descriptor('active-2', 'active'), + descriptor('active-3', 'active'), + descriptor('active-4', 'active'), + descriptor('active-5', 'active'), + ]); + + expect(tabKeys(result)).toEqual([ + 'active-1', + 'active-2', + 'active-3', + 'active-4', + ]); + expect(drawerKeys(result)).toEqual(['active-5']); + expect(result.overflowCount).toBe(1); + expect(result.overflowLabelMode).toBe('more'); + }); + + it('lingers recent completions and settles them at the exact expiry', () => { + const result = partition( + [ + descriptor('recent-done', 'done'), + descriptor('expired-done', 'done'), + descriptor('untracked-error', 'error'), + ], + { + 'recent-done': NOW - FINISHED_LINGER_MS + 1, + 'expired-done': NOW - FINISHED_LINGER_MS, + } + ); + + expect(tabKeys(result)).toEqual(['recent-done']); + expect(result.tabs[0].lingering).toBe(true); + expect(drawerKeys(result)).toEqual(['expired-done', 'untracked-error']); + expect(result.doneCount).toBe(3); + expect(result.overflowLabelMode).toBe('done'); + }); + + it('gives active tasks the tab budget before lingering tasks', () => { + const result = partition( + [ + descriptor('linger-1', 'done'), + descriptor('active-1', 'active'), + descriptor('linger-2', 'error'), + descriptor('active-2', 'active'), + descriptor('settled-1', 'done'), + ], + { + 'linger-1': NOW - 100, + 'linger-2': NOW - 200, + 'settled-1': NOW - FINISHED_LINGER_MS, + }, + 3 + ); + + expect(tabKeys(result)).toEqual(['active-1', 'active-2', 'linger-1']); + expect(result.tabs.map((tab) => tab.lingering)).toEqual([ + false, + false, + true, + ]); + expect(drawerKeys(result)).toEqual(['linger-2', 'settled-1']); + expect(result.activeCount).toBe(2); + expect(result.doneCount).toBe(3); + expect(result.overflowLabelMode).toBe('done'); + }); + + it('uses the done label when every hidden task is non-active', () => { + const result = partition( + [ + descriptor('done-1', 'done'), + descriptor('error-1', 'error'), + descriptor('done-2', 'done'), + ], + { + 'done-1': NOW - 100, + 'error-1': NOW - 200, + 'done-2': NOW - 300, + }, + 1 + ); + + expect(tabKeys(result)).toEqual(['done-1']); + expect(drawerKeys(result)).toEqual(['error-1', 'done-2']); + expect(result.overflowLabelMode).toBe('done'); + expect(result.doneCount).toBe(3); + }); + + it('keeps deterministic spawn order within each drawer group', () => { + const result = partition( + [ + descriptor('settled-1', 'done'), + descriptor('active-1', 'active'), + descriptor('linger-1', 'done'), + descriptor('active-2', 'active'), + descriptor('settled-2', 'error'), + descriptor('linger-2', 'error'), + ], + { + 'settled-1': NOW - FINISHED_LINGER_MS, + 'linger-1': NOW - 100, + 'settled-2': NOW - FINISHED_LINGER_MS - 1, + 'linger-2': NOW - 200, + }, + 1 + ); + + expect(tabKeys(result)).toEqual(['active-1']); + expect(drawerKeys(result)).toEqual([ + 'active-2', + 'linger-1', + 'linger-2', + 'settled-1', + 'settled-2', + ]); + expect(result.overflowLabelMode).toBe('more'); + }); +}); diff --git a/packages/web-core/src/features/workspace-chat/ui/DisplayConversationEntry.tsx b/packages/web-core/src/features/workspace-chat/ui/DisplayConversationEntry.tsx index 6287b15ab6..e4871361e0 100644 --- a/packages/web-core/src/features/workspace-chat/ui/DisplayConversationEntry.tsx +++ b/packages/web-core/src/features/workspace-chat/ui/DisplayConversationEntry.tsx @@ -477,7 +477,7 @@ function FileEntryDiffBody({ ); } -function AppChatMarkdown({ +export function AppChatMarkdown({ content, workspaceId, sessionId, diff --git a/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentDrawer.tsx b/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentDrawer.tsx new file mode 100644 index 0000000000..5338557580 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentDrawer.tsx @@ -0,0 +1,154 @@ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CaretDownIcon } from '@phosphor-icons/react'; + +import { + ChatSubagentEntry, + type ChatSubagentEntryRenderProps, +} from '@vibe/ui/components/ChatSubagentEntry'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@vibe/ui/components/Popover'; +import { cn } from '@vibe/ui/lib/cn'; +import type { SubagentDescriptor } from '../../model/subagent-strip-model'; +import { AppChatMarkdown } from '../DisplayConversationEntry'; + +interface SubagentDetailListProps { + descriptors: SubagentDescriptor[]; + workspaceId?: string; + sessionId?: string; +} + +export function SubagentDetailList({ + descriptors, + workspaceId, + sessionId, +}: SubagentDetailListProps) { + const { t } = useTranslation('common'); + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set() + ); + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + const renderMarkdown = useCallback( + ({ + content, + workspaceId: renderedWorkspaceId, + }: ChatSubagentEntryRenderProps) => ( + + ), + [sessionId] + ); + + return ( +
+ {descriptors.map((descriptor) => { + const hasResult = Boolean(descriptor.result?.value); + const statusLabel = + descriptor.phase === 'active' + ? t('conversation.subagentStrip.stillRunning') + : descriptor.phase === 'done' + ? t('conversation.subagentStrip.completed') + : t('conversation.subagentStrip.failed'); + + return ( +
+ { + toggleExpanded(descriptor.key); + } + : undefined + } + status={descriptor.status} + workspaceId={workspaceId} + renderMarkdown={renderMarkdown} + /> + {statusLabel} +
+ ); + })} +
+ ); +} + +interface SubagentOverflowProps extends SubagentDetailListProps { + count: number; + labelMode: 'more' | 'done'; +} + +export function SubagentOverflow({ + descriptors, + count, + labelMode, + workspaceId, + sessionId, +}: SubagentOverflowProps) { + const { t } = useTranslation('common'); + const [open, setOpen] = useState(false); + const label = + labelMode === 'more' + ? t('conversation.subagentStrip.more', { count }) + : t('conversation.subagentStrip.done', { count }); + const title = + labelMode === 'more' + ? t('conversation.subagentStrip.allSubagents') + : t('conversation.subagentStrip.drawerTitle'); + + return ( + + + + + +

{title}

+
+ +
+
+
+ ); +} diff --git a/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentStrip.tsx b/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentStrip.tsx new file mode 100644 index 0000000000..1f86e3f37f --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentStrip.tsx @@ -0,0 +1,240 @@ +import { useId, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { BaseCodingAgent } from 'shared/types'; +import { CaretDownIcon, CpuIcon, UserCircleIcon } from '@phosphor-icons/react'; + +import { RunningDots } from '@vibe/ui/components/RunningDots'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@vibe/ui/components/Popover'; +import { cn } from '@vibe/ui/lib/cn'; +import { AgentIcon } from '@/shared/components/AgentIcon'; +import { useIsMobile } from '@/shared/hooks/useIsMobile'; +import { useSubagentStrip } from '../../model/hooks/useSubagentStrip'; +import type { + StripTab, + SubagentDescriptor, +} from '../../model/subagent-strip-model'; +import { SubagentDetailList, SubagentOverflow } from './SubagentDrawer'; +import { SubagentTab } from './SubagentTab'; + +interface SubagentStripProps { + executor?: BaseCodingAgent | null; + workspaceId?: string; + sessionId?: string; +} + +interface StripViewProps extends SubagentStripProps { + tabs: StripTab[]; + drawer: SubagentDescriptor[]; + overflowCount: number; + overflowLabelMode: 'more' | 'done'; + activeCount: number; + doneCount: number; +} + +function MainChip({ executor }: Pick) { + const { t } = useTranslation('common'); + + return ( +
+ {executor ? ( + + ) : ( + + )} + {t('conversation.subagentStrip.main')} +
+ ); +} + +function DesktopSubagentStrip({ + executor, + workspaceId, + sessionId, + tabs, + drawer, + overflowCount, + overflowLabelMode, +}: StripViewProps) { + const { t } = useTranslation('common'); + + return ( +
+
+ +
+ {tabs.map(({ descriptor }) => ( + + ))} +
+ {overflowCount > 0 ? ( + + ) : null} +
+
+ ); +} + +function MobileActiveSubagent({ + descriptor, +}: { + descriptor: SubagentDescriptor; +}) { + const { t } = useTranslation('common'); + const name = descriptor.name + ? descriptor.name.charAt(0).toUpperCase() + descriptor.name.slice(1) + : t('conversation.subagent.defaultType'); + + return ( +
+ +
+ + {name} + + + {descriptor.description} + +
+ + {t('conversation.subagentStrip.working')} +
+ ); +} + +function MobileSubagentStrip({ + workspaceId, + sessionId, + tabs, + drawer, + activeCount, + doneCount, +}: StripViewProps) { + const { t } = useTranslation('common'); + const [open, setOpen] = useState(false); + const panelId = useId(); + const descriptors = useMemo( + () => [...tabs.map(({ descriptor }) => descriptor), ...drawer], + [drawer, tabs] + ); + const active = descriptors.filter( + (descriptor) => descriptor.phase === 'active' + ); + const finished = descriptors.filter( + (descriptor) => descriptor.phase !== 'active' + ); + + return ( + +
+ + + + +
+ {active.length > 0 ? ( +
+

+ {t('conversation.subagentStrip.stillRunning')} +

+ {active.map((descriptor) => ( + + ))} +
+ ) : null} + {finished.length > 0 ? ( +
+

+ {t('conversation.subagentStrip.drawerTitle')} +

+ +
+ ) : null} +
+
+
+
+ ); +} + +export function SubagentStrip({ + executor, + workspaceId, + sessionId, +}: SubagentStripProps) { + const strip = useSubagentStrip(); + const isMobile = useIsMobile(); + + if (!strip.hasAny) return null; + + const props: StripViewProps = { + executor, + workspaceId, + sessionId, + tabs: strip.tabs, + drawer: strip.drawer, + overflowCount: strip.overflowCount, + overflowLabelMode: strip.overflowLabelMode, + activeCount: strip.activeCount, + doneCount: strip.doneCount, + }; + + return isMobile ? ( + + ) : ( + + ); +} diff --git a/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentTab.tsx b/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentTab.tsx new file mode 100644 index 0000000000..800b3927f7 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/ui/subagent-strip/SubagentTab.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckCircleIcon, CpuIcon, XCircleIcon } from '@phosphor-icons/react'; + +import { RunningDots } from '@vibe/ui/components/RunningDots'; +import { cn } from '@vibe/ui/lib/cn'; +import type { SubagentDescriptor } from '../../model/subagent-strip-model'; + +function formatSubagentName(name: string | null, fallback: string): string { + if (!name) return fallback; + return name.charAt(0).toUpperCase() + name.slice(1); +} + +interface SubagentTabProps { + descriptor: SubagentDescriptor; +} + +export function SubagentTab({ descriptor }: SubagentTabProps) { + const { t } = useTranslation('common'); + const displayName = useMemo( + () => + formatSubagentName( + descriptor.name, + t('conversation.subagent.defaultType') + ), + [descriptor.name, t] + ); + const isActive = descriptor.phase === 'active'; + const toneClass = + descriptor.phase === 'error' + ? 'border-error/40 bg-error/5 text-error' + : descriptor.phase === 'done' + ? 'border-success/40 bg-success/5 text-normal' + : 'border-border bg-panel text-normal'; + + return ( +
+ + + {displayName} + + {isActive ? ( + <> + + + {t('conversation.subagentStrip.working')} + + + ) : descriptor.phase === 'error' ? ( + <> + + + {t('conversation.subagentStrip.failed')} + + + ) : ( + <> + + + {t('conversation.subagentStrip.completed')} + + + )} +
+ ); +} diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index 076455411b..5f913b51c9 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -108,6 +108,20 @@ "subagent": { "defaultType": "Subagent" }, + "subagentStrip": { + "main": "Main", + "working": "Working", + "completed": "Completed", + "failed": "Failed", + "done": "{{count}} done", + "done_other": "{{count}} done", + "more": "{{count}} more", + "more_other": "{{count}} more", + "mobileSummary": "{{active}} active · {{done}} done", + "drawerTitle": "Finished subagents", + "stillRunning": "Running", + "allSubagents": "Subagents" + }, "fork": { "explanation": "Some messages went down a path the agent is no longer following.", "branchLabel": "Branch {{index}}", diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 0d7035f1d3..cd005fd452 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -116,6 +116,20 @@ "subagent": { "defaultType": "Subagente" }, + "subagentStrip": { + "main": "Principal", + "working": "Trabajando", + "completed": "Completado", + "failed": "Fallido", + "done": "{{count}} finalizado", + "done_other": "{{count}} finalizados", + "more": "{{count}} más", + "more_other": "{{count}} más", + "mobileSummary": "{{active}} activos · {{done}} finalizados", + "drawerTitle": "Subagentes finalizados", + "stillRunning": "En ejecución", + "allSubagents": "Subagentes" + }, "fork": { "explanation": "Algunos mensajes siguieron un camino que el agente ya no está siguiendo.", "branchLabel": "Rama {{index}}", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 882efda586..2b7fa67767 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -103,6 +103,20 @@ "subagent": { "defaultType": "Sous-agent" }, + "subagentStrip": { + "main": "Principal", + "working": "En cours", + "completed": "Terminé", + "failed": "Échec", + "done": "{{count}} terminé", + "done_other": "{{count}} terminés", + "more": "{{count}} autre", + "more_other": "{{count}} autres", + "mobileSummary": "{{active}} actifs · {{done}} terminés", + "drawerTitle": "Sous-agents terminés", + "stillRunning": "En cours", + "allSubagents": "Sous-agents" + }, "fork": { "explanation": "Certains messages ont suivi un chemin que l'agent ne suit plus.", "branchLabel": "Branche {{index}}", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index 828a6444c2..be6cebadbc 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -116,6 +116,20 @@ "subagent": { "defaultType": "サブエージェント" }, + "subagentStrip": { + "main": "メイン", + "working": "作業中", + "completed": "完了", + "failed": "失敗", + "done": "{{count}} 件完了", + "done_other": "{{count}} 件完了", + "more": "ほか {{count}} 件", + "more_other": "ほか {{count}} 件", + "mobileSummary": "実行中 {{active}} 件 · 完了 {{done}} 件", + "drawerTitle": "完了したサブエージェント", + "stillRunning": "実行中", + "allSubagents": "サブエージェント" + }, "fork": { "explanation": "一部のメッセージは、エージェントが現在たどっていない経路に進みました。", "branchLabel": "ブランチ {{index}}", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index 96480bc84b..dfc28ea9a8 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -116,6 +116,20 @@ "subagent": { "defaultType": "서브에이전트" }, + "subagentStrip": { + "main": "메인", + "working": "작업 중", + "completed": "완료", + "failed": "실패", + "done": "{{count}}개 완료", + "done_other": "{{count}}개 완료", + "more": "{{count}}개 더", + "more_other": "{{count}}개 더", + "mobileSummary": "활성 {{active}}개 · 완료 {{done}}개", + "drawerTitle": "완료된 서브에이전트", + "stillRunning": "실행 중", + "allSubagents": "서브에이전트" + }, "fork": { "explanation": "일부 메시지는 에이전트가 더 이상 따르지 않는 경로로 진행되었습니다.", "branchLabel": "브랜치 {{index}}", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index cd9bc7f20b..effd1d610b 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -103,6 +103,20 @@ "subagent": { "defaultType": "子代理" }, + "subagentStrip": { + "main": "主代理", + "working": "工作中", + "completed": "已完成", + "failed": "失败", + "done": "{{count}} 个已完成", + "done_other": "{{count}} 个已完成", + "more": "另有 {{count}} 个", + "more_other": "另有 {{count}} 个", + "mobileSummary": "{{active}} 个运行中 · {{done}} 个已完成", + "drawerTitle": "已完成的子代理", + "stillRunning": "运行中", + "allSubagents": "子代理" + }, "fork": { "explanation": "部分消息进入了代理不再继续的路径。", "branchLabel": "分支 {{index}}", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 2cc06255a3..f2afb1a8cf 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -103,6 +103,20 @@ "subagent": { "defaultType": "子代理" }, + "subagentStrip": { + "main": "主代理", + "working": "工作中", + "completed": "已完成", + "failed": "失敗", + "done": "{{count}} 個已完成", + "done_other": "{{count}} 個已完成", + "more": "另有 {{count}} 個", + "more_other": "另有 {{count}} 個", + "mobileSummary": "{{active}} 個執行中 · {{done}} 個已完成", + "drawerTitle": "已完成的子代理", + "stillRunning": "執行中", + "allSubagents": "子代理" + }, "fork": { "explanation": "部分訊息進入了代理不再繼續的路徑。", "branchLabel": "分支 {{index}}", diff --git a/packages/web-core/src/pages/workspaces/VSCodeWorkspacePage.tsx b/packages/web-core/src/pages/workspaces/VSCodeWorkspacePage.tsx index decb3fac10..a7938a5644 100644 --- a/packages/web-core/src/pages/workspaces/VSCodeWorkspacePage.tsx +++ b/packages/web-core/src/pages/workspaces/VSCodeWorkspacePage.tsx @@ -2,7 +2,7 @@ import '@/integrations/vscode/bridge'; import { useCallback, useEffect, useRef, useState } from 'react'; -import type { Session } from 'shared/types'; +import type { BaseCodingAgent, Session } from 'shared/types'; import { useTranslation } from 'react-i18next'; import { AppWithStyleOverride } from '@/shared/lib/StyleOverride'; import { useStyleOverrideThemeSetter } from '@/shared/lib/StyleOverride'; @@ -21,6 +21,7 @@ import { MessageEditProvider } from '@/features/workspace-chat/model/contexts/Me import { RetryUiProvider } from '@/features/workspace-chat/model/contexts/RetryUiContext'; import { ApprovalFeedbackProvider } from '@/features/workspace-chat/model/contexts/ApprovalFeedbackContext'; import { forwardWheelToScroller } from '@/features/workspace-chat/ui/forwardWheelToScroller'; +import { SubagentStrip } from '@/features/workspace-chat/ui/subagent-strip/SubagentStrip'; import { createWorkspaceWithSession } from '@/shared/types/attempt'; function VSCodeChatBox({ @@ -202,16 +203,28 @@ export function VSCodeWorkspacePage() { forwardWheelToScroller(e, conversationListRef) } > -
+
- +
+ +
diff --git a/packages/web-core/src/pages/workspaces/WorkspacesMainContainer.tsx b/packages/web-core/src/pages/workspaces/WorkspacesMainContainer.tsx index 8bb178e388..a199e56c4d 100644 --- a/packages/web-core/src/pages/workspaces/WorkspacesMainContainer.tsx +++ b/packages/web-core/src/pages/workspaces/WorkspacesMainContainer.tsx @@ -7,7 +7,12 @@ import { useRef, useState, } from 'react'; -import type { Workspace, Session, RepoWithTargetBranch } from 'shared/types'; +import type { + BaseCodingAgent, + Workspace, + Session, + RepoWithTargetBranch, +} from 'shared/types'; import { createWorkspaceWithSession } from '@/shared/types/attempt'; import { WorkspacesMain } from '@vibe/ui/components/WorkspacesMain'; import { @@ -21,6 +26,7 @@ import { MessageEditProvider } from '@/features/workspace-chat/model/contexts/Me import { RetryUiProvider } from '@/features/workspace-chat/model/contexts/RetryUiContext'; import { ApprovalFeedbackProvider } from '@/features/workspace-chat/model/contexts/ApprovalFeedbackContext'; import { forwardWheelToScroller } from '@/features/workspace-chat/ui/forwardWheelToScroller'; +import { SubagentStrip } from '@/features/workspace-chat/ui/subagent-strip/SubagentStrip'; import { useDiffStats } from '@/shared/stores/useWorkspaceDiffStore'; /** @@ -207,21 +213,28 @@ export const WorkspacesMainContainer = forwardRef< className="flex-1 min-h-0 overflow-hidden flex justify-center" onWheel={(e) => forwardWheelToScroller(e, conversationListRef)} > -
+
- +
+ +