Skip to content

feat: claude agent teams UI support#1145

Draft
JustYannicc wants to merge 11 commits intopingdotgg:mainfrom
JustYannicc:codex/pr-179-refresh
Draft

feat: claude agent teams UI support#1145
JustYannicc wants to merge 11 commits intopingdotgg:mainfrom
JustYannicc:codex/pr-179-refresh

Conversation

@JustYannicc
Copy link

@JustYannicc JustYannicc commented Mar 16, 2026

What Changed

Adds comprehensive UI support for Claude Code's experimental Agent Teams feature.

Status Tracking

  • Correct agent status transitions (running → idle → completed/failed/stopped) with terminal status guards preventing regression
  • tool.completed → idle mapping, task.progress/task.completed tracking via teammateTaskIds
  • Run-level status accounts for endedAt — ended runs show terminal status and zero active count

Team Lifecycle

  • Proper shutdown detection via TeamDelete and team.run.ended signals
  • Lead coordination tools (TeamCreate/TeamDelete/SendMessage/TaskCreate/TaskUpdate/TaskDelete) excluded from member creation
  • SendMessage activities routed to target member's activity feed

Panel UI

  • Dense master-detail layout replacing the previous card grid + sidebar
  • Left column: clickable agent rows with name, status icon, and current tool
  • Right column: identity header + unified activity feed merging activities and tasks chronologically
  • Color name resolution via explicit map (red→rose, blue→sky, etc.)
  • Max-height scroll constraints; panel detaches from top when team ends

Settings

  • Teammate mode selector (Auto / In-process / Tmux)
  • Agent teams enable toggle and progress summaries toggle

Adapter (teams-specific)

  • in_process_teammate task type recognition in ingestion pipeline
  • Teammate name extraction from detail prefix pattern
  • Team tool classification and enriched summaries

Why

The agent teams feature was released in Claude Code v2.1.32 but the T3 Code UI had no support for visualizing team activity, tracking agent status, or managing team lifecycle. This PR adds that support, validated against the reverse-engineered Claude Code CLI v2.1.76 event protocol.

Depends on: #179 and #1146 being merged first.

UI Changes

  • New expandable agent teams panel below the chat header (only visible during active team sessions)
  • Team-run timeline entries showing team name, summary, member count, and duration
  • Agent list with colored dots, status icons, and current tool names
  • Unified activity feed with relative timestamps and vertical timeline

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • 62 unit tests + 3 E2E tests with real Claude API (Haiku 4.5)
  • Type checks clean across web, server, and contracts
  • 3 independent code reviews completed

JustYannicc and others added 11 commits March 15, 2026 19:06
Status tracking:
- Add tool.completed → idle mapping so agents transition correctly between tools
- Fix status fallback to preserve current status instead of forcing "running"
- Guard terminal status from regression by late tool.completed events
- Compute endedAt before status in finalization so ended runs show correct state
- Track teammate taskIds across task.progress/task.completed events
- Fall back to ended runs by runId for late-arriving events

Shutdown detection:
- Handle team.run.ended in main loop before shouldTrackAgentTeamsActivity filter
- Treat TeamDelete tool.completed as team shutdown signal
- Force non-terminal members to completed when run ends
- Auto-close stale synthetic turns in sendTurn to prevent session deadlock

Lead tool filtering:
- TeamCreate/TeamDelete/TeamUpdate/SendMessage/TaskCreate/TaskUpdate/TaskDelete
  no longer create phantom team members
- Route SendMessage activities to target member's activity feed
- Classify team management tools as collab_agent_tool_call in adapter

Adapter improvements:
- Accumulate input_json_delta fragments for tool input reconstruction
- Fix SendMessage field names (recipient/content per SDK schema)
- Extract teammateName from toolInput.recipient for SendMessage tools
- Auto-start synthetic turns for assistant messages arriving without turnState
  (fixes invisible teammate responses between user prompts)
- Clear inFlightTools between turns to prevent stale fragment corruption
- Treat "auto" teammateMode as undefined so fallback to in-process fires

Ingestion:
- Recognize taskType "in_process_teammate" as team metadata
- Extract teammate names from detail prefix ("name: description...")
- Infer teammateName for task.started/progress/completed payloads

UI panel redesign:
- Replace redundant card grid + sidebar with compact master-detail layout
- Left column: clickable agent rows with name, status, current tool
- Right column: identity header + unified activity feed (activities + tasks)
- Fix COLOR_NAME_MAP for direct color name lookup (red→rose, blue→sky, etc.)
- Fix panelSummary double-counting idle in active count
- Add formatRelativeTime and buildUnifiedFeed helpers
- Max-height scroll constraints on panel and activity feed
- Panel detaches from top when team ends (shows in timeline instead)

Settings:
- Add claudeTeammateMode setting (auto/in-process/tmux) with UI dropdown
- Forward teammateMode through provider options to adapter
- Add teammateMode to ClaudeCodeProviderStartOptions contract

Timeline:
- Enriched team-run entries with memberCount, summary, duration badge
- Styled team-run cards with icon and elapsed time

Tests:
- 15 new unit tests covering full team lifecycle, phantom member prevention,
  status transitions, TeamDelete shutdown, task.progress tracking, SendMessage
  routing, multiple runs, and terminal status regression
- 3 E2E tests using real Claude API (Haiku 4.5): team lifecycle, subagent
  result flow, background command capture

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@coderabbitai
Copy link

coderabbitai bot commented Mar 16, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bebf225d-2380-468d-9ff9-d278f15f6828

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable sequence diagrams in the walkthrough.

Disable the reviews.sequence_diagrams setting to disable sequence diagrams in the walkthrough.

@github-actions github-actions bot added the size:XXL 1,000+ changed lines (additions + deletions). label Mar 16, 2026
@github-actions github-actions bot added the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Mar 16, 2026
@JustYannicc JustYannicc marked this pull request as draft March 16, 2026 19:54
@JustYannicc
Copy link
Author

JustYannicc commented Mar 16, 2026

I am still working on the UI. Will update with screenshots. But filed for now since I was asked to split it out in #179

stopped: false,
};
yield* Ref.set(contextRef, context);
sessions.set(threadId, context);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/ClaudeCodeAdapter.ts:2174

sessions.set(threadId, context) at line 2174 silently overwrites any existing session with the same threadId. The previous session's resources—the forked runSdkStream fiber, promptQueue, and queryRuntime—become orphaned and continue running indefinitely, and the finalizer only cleans up sessions still present in the sessions map. Consider either rejecting duplicate threadIds with an error, or explicitly stopping the existing session before overwriting it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/ClaudeCodeAdapter.ts around line 2174:

`sessions.set(threadId, context)` at line 2174 silently overwrites any existing session with the same `threadId`. The previous session's resources—the forked `runSdkStream` fiber, `promptQueue`, and `queryRuntime`—become orphaned and continue running indefinitely, and the finalizer only cleans up sessions still present in the `sessions` map. Consider either rejecting duplicate `threadId`s with an error, or explicitly stopping the existing session before overwriting it.

Evidence trail:
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts line 864 (sessions map declaration), lines 1920-1924 (startSession using threadId without checking existing sessions), line 2174 (`sessions.set(threadId, context)` without has() check), line 2228 (`Effect.runFork(runSdkStream(context))` forking detached fiber), line 1884 (`sessions.delete(context.session.threadId)` cleanup only for sessions in map). git_grep for `sessions.has` returned no results, confirming no duplicate check exists.

Comment on lines +174 to +182
function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low chat/AgentTeamsPanel.tsx:174

When formatRelativeTime receives an invalid date string (e.g., empty string or malformed), new Date(iso).getTime() returns NaN. Since NaN < 1, NaN < 60, and NaN < 24 are all false, the function falls through to return "NaNd ago". Consider adding a Number.isNaN guard to match the pattern used in formatTimestamp.

function formatRelativeTime(iso: string): string {
+  const time = new Date(iso).getTime();
+  if (Number.isNaN(time)) return iso;
+  const diff = Date.now() - time;
-  const diff = Date.now() - new Date(iso).getTime();
   const minutes = Math.floor(diff / 60_000);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/chat/AgentTeamsPanel.tsx around lines 174-182:

When `formatRelativeTime` receives an invalid date string (e.g., empty string or malformed), `new Date(iso).getTime()` returns `NaN`. Since `NaN < 1`, `NaN < 60`, and `NaN < 24` are all `false`, the function falls through to return `"NaNd ago"`. Consider adding a `Number.isNaN` guard to match the pattern used in `formatTimestamp`.

Evidence trail:
apps/web/src/components/chat/AgentTeamsPanel.tsx lines 160-181 at REVIEWED_COMMIT: `formatTimestamp` (lines 160-171) has `Number.isNaN` guard at line 162-164, while `formatRelativeTime` (lines 173-181) lacks this guard. JavaScript specification: NaN comparisons with any number return false, causing the function to fall through all conditionals.

Comment on lines +280 to +281
const awaitingLeaderApproval =
asBoolean(value?.awaitingLeaderApproval) ?? asBoolean(value?.awaiting_leader_approval);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low Layers/ClaudeCodeAdapter.ts:280

The property access order for awaitingLeaderApproval at line 280 is inverted: it checks camelCase first (value?.awaitingLeaderApproval) then falls back to snake_case, while every other field checks snake_case first. If input contains both naming conventions with different values, this field resolves to a different value than all others.

-  const awaitingLeaderApproval =
-    asBoolean(value?.awaitingLeaderApproval) ?? asBoolean(value?.awaiting_leader_approval);
+  const awaitingLeaderApproval =
+    asBoolean(value?.awaiting_leader_approval) ?? asBoolean(value?.awaitingLeaderApproval);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/ClaudeCodeAdapter.ts around lines 280-281:

The property access order for `awaitingLeaderApproval` at line 280 is inverted: it checks `camelCase` first (`value?.awaitingLeaderApproval`) then falls back to `snake_case`, while every other field checks `snake_case` first. If input contains both naming conventions with different values, this field resolves to a different value than all others.

Evidence trail:
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts lines 270-281 at REVIEWED_COMMIT showing:
- Lines 270-279: All other fields check snake_case first, then camelCase (e.g., `value?.team_name ?? value?.teamName`)
- Lines 280-281: `awaitingLeaderApproval` checks camelCase first, then snake_case (`value?.awaitingLeaderApproval ?? value?.awaiting_leader_approval`)

Comment on lines +943 to +945
if (teamKey) {
byTeamKey.set(teamKey, snapshot);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low src/session-logic.ts:943

When a runId's teamKey changes from "A" to "B" across activities, line 944 only sets byTeamKey.set("B", snapshot) without removing the stale entry for "A". The old byTeamKey.get("A") still returns the outdated snapshot, leaving the index in an inconsistent state where both keys reference different versions of the same run. Consider deleting the old teamKey entry when it differs from the current one, or rebuild the index differently.

-    if (teamKey) {
+    if (teamKey && teamKey !== existing?.teamKey) {
+      if (existing?.teamKey) {
+        byTeamKey.delete(existing.teamKey);
+      }
       byTeamKey.set(teamKey, snapshot);
     }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/session-logic.ts around lines 943-945:

When a `runId`'s `teamKey` changes from "A" to "B" across activities, line 944 only sets `byTeamKey.set("B", snapshot)` without removing the stale entry for "A". The old `byTeamKey.get("A")` still returns the outdated snapshot, leaving the index in an inconsistent state where both keys reference different versions of the same run. Consider deleting the old `teamKey` entry when it differs from the current one, or rebuild the index differently.

Evidence trail:
apps/web/src/session-logic.ts lines 830-835 (Map creation and teamKey determination), lines 942-945 (setting entries without deleting old teamKey). The existing?.teamKey is retrieved at line 835 but there's no comparison or deletion of the old key when teamKey changes before lines 942-945.

return "dynamic_tool_call";
}

function classifyRequestType(toolName: string): CanonicalRequestType {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low Layers/ClaudeCodeAdapter.ts:416

classifyRequestType returns "file_change_approval" as the fallback for any tool that isn't a read operation or command execution. This incorrectly classifies MCP tools, collab agent tools, and dynamic tool calls as file change approvals. For example, a tool named "mcp_weather" gets classifyToolItemType return of "mcp_tool_call", which is not "command_execution", so classifyRequestType returns "file_change_approval" — semantically incorrect since an MCP weather call is not a file change. Consider adding explicit cases for MCP tools and collab agent tools, or mapping each CanonicalItemType to its corresponding CanonicalRequestType instead of defaulting to file change approval.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/ClaudeCodeAdapter.ts around line 416:

`classifyRequestType` returns `"file_change_approval"` as the fallback for any tool that isn't a read operation or command execution. This incorrectly classifies MCP tools, collab agent tools, and dynamic tool calls as file change approvals. For example, a tool named `"mcp_weather"` gets `classifyToolItemType` return of `"mcp_tool_call"`, which is not `"command_execution"`, so `classifyRequestType` returns `"file_change_approval"` — semantically incorrect since an MCP weather call is not a file change. Consider adding explicit cases for MCP tools and collab agent tools, or mapping each `CanonicalItemType` to its corresponding `CanonicalRequestType` instead of defaulting to file change approval.

Evidence trail:
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts lines 375-424 (REVIEWED_COMMIT): `classifyToolItemType` returns "mcp_tool_call" for tools containing "mcp" and "collab_agent_tool_call" for team/agent tools. `classifyRequestType` (lines 417-424) only checks for "command_execution" and falls back to "file_change_approval" for all other types, including "mcp_tool_call", "collab_agent_tool_call", and "dynamic_tool_call".

Comment on lines +34 to +37
export const CLAUDE_CLI_PATH = path.join(
path.dirname(require.resolve("@anthropic-ai/claude-agent-sdk")),
"cli.js",
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Layers/ProviderHealth.ts:34

CLAUDE_CLI_PATH is evaluated at module load time via require.resolve, so a missing @anthropic-ai/claude-agent-sdk package throws before checkClaudeProviderStatus runs and causes the entire server process to crash. The error handling that reports "Claude Code runtime is not available" is unreachable because the module fails to load first. Consider wrapping the resolution in a try/catch or moving it into checkClaudeProviderStatus so the health check can gracefully report the provider as unavailable.

-export const CLAUDE_CLI_PATH = path.join(
-  path.dirname(require.resolve("@anthropic-ai/claude-agent-sdk")),
-  "cli.js",
-);
+export let CLAUDE_CLI_PATH: string | undefined;
+try {
+  CLAUDE_CLI_PATH = path.join(
+    path.dirname(require.resolve("@anthropic-ai/claude-agent-sdk")),
+    "cli.js",
+  );
+} catch {
+  CLAUDE_CLI_PATH = undefined;
+}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/ProviderHealth.ts around lines 34-37:

`CLAUDE_CLI_PATH` is evaluated at module load time via `require.resolve`, so a missing `@anthropic-ai/claude-agent-sdk` package throws before `checkClaudeProviderStatus` runs and causes the entire server process to crash. The error handling that reports "Claude Code runtime is not available" is unreachable because the module fails to load first. Consider wrapping the resolution in a try/catch or moving it into `checkClaudeProviderStatus` so the health check can gracefully report the provider as unavailable.

Evidence trail:
apps/server/src/provider/Layers/ProviderHealth.ts lines 32-35: `const require = createRequire(import.meta.url); export const CLAUDE_CLI_PATH = path.join(path.dirname(require.resolve("@anthropic-ai/claude-agent-sdk")), "cli.js");` - module-level code that calls require.resolve at load time.

apps/server/src/provider/Layers/ProviderHealth.ts lines 455-466: `checkClaudeProviderStatus` contains the error handling that reports "Claude Code runtime is not available" but this code is unreachable if the module fails to load.

Node.js documentation on require.resolve: throws MODULE_NOT_FOUND if the module cannot be found.

Effect.result,
);

if (Result.isFailure(authProbe)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Layers/ProviderHealth.ts:619

When the auth probe fails or times out, checkClaudeProviderStatus returns a ServerProviderStatus that omits the capabilities field, while all other return paths include it. Since parsedVersion is already extracted at line 595, the capabilities object can be populated in these branches as well.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/provider/Layers/ProviderHealth.ts around line 619:

When the auth probe fails or times out, `checkClaudeProviderStatus` returns a `ServerProviderStatus` that omits the `capabilities` field, while all other return paths include it. Since `parsedVersion` is already extracted at line 595, the capabilities object can be populated in these branches as well.

Evidence trail:
apps/server/src/provider/Layers/ProviderHealth.ts lines 595, 619-631, 634-643, 562-577, 579-592, 596-612, 645-657 (at REVIEWED_COMMIT). packages/contracts/src/server.ts lines 56-66 for ServerProviderStatus schema definition.

@juliusmarminge
Copy link
Member

we should find a more generic solution to subagent as a whole. this is currently very claude focus and we'd like the frontend to be as agnostic as possible

@juliusmarminge
Copy link
Member

Can you share some screenshots/videos of how your implementation looks?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants