feat: normalized external-agent status (#10 scoped) (v2.7.6)

Scoped half of boocode_code_review_v2 §1 #10 — publish the agent status
BooCoder already observes (the config-injection notify-hook is the documented
follow-on, clean-room from superset ELv2).

- agent_status_updated WS frame (working|blocked|idle|error), server+web parity.
- Published from the dispatcher's turn boundaries (warm-acp/opencode/sdk/pty:
  working at start, idle/error at end) + the permission flow (blocked/working).
  Best-effort, never breaks a turn.
- Clean-room normalizeAgentEvent helper (superset's vendor-event -> Start/blocked
  /Stop collapse, event names as facts) + 25 tests — reused by the follow-on.
- AgentComposerBar status dot (distinct from the WS-liveness dot), tracked per
  (chat,agent) by a useAgentStatus map in CoderPane.

Built by 2 parallel agents vs a pinned frame contract. Server 545 + coder 294
tests passing (25 new); web tsc + builds clean; ws-frames parity green. Clears
the actionable review backlog (#1/#3/#4/#6-#12). Builds on v2.7.5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 14:04:04 +00:00
parent 6fc3175730
commit 59cf082e06
14 changed files with 623 additions and 7 deletions

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'luci
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
import { providerIcon } from '@/components/coder/providerIcons';
import { useAgentSessions } from '@/hooks/useAgentSessions';
import {
@@ -183,6 +184,11 @@ interface Props {
// True once the chat has at least one prior turn — gates the chip so it stays
// hidden on a brand-new chat. Defaults to false (no chip).
hasPriorTurn?: boolean;
// #10: normalized status (working|blocked|idle|error) for the active external
// agent in this chat, or null for native boocode / before any frame. Renders
// a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for
// non-coder callers — no dot.
agentStatus?: AgentStatusEntry | null;
}
// Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M".
@@ -210,7 +216,42 @@ function relativeTime(iso: string | null): string {
return `${day}d ago`;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: Props) {
// #10: normalized external-agent status dot. Mirrors StatusDot's visual
// language but on the four normalized buckets (working|blocked|idle|error),
// and is DISTINCT from the WS-liveness `connected` dot beside it:
// working — emerald spinning ring (subtle motion, like chat streaming)
// blocked — amber dot (matches the permission/blocked state colour)
// idle — gray dot
// error — red dot
function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: string }) {
const title =
`${agent}: ${entry.status}` + (entry.reason ? `${entry.reason}` : '');
if (entry.status === 'working') {
return (
<span
aria-label={`Agent status: working${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
/>
);
}
const bg =
entry.status === 'blocked' ? 'bg-amber-500'
: entry.status === 'error' ? 'bg-destructive'
: 'bg-muted-foreground/40';
return (
<span
aria-label={`Agent status: ${entry.status}${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', bg)}
/>
);
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn, agentStatus }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are
@@ -434,6 +475,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
{/* Status dot + refresh as one right-aligned unit so the refresh button
stays on the top line instead of wrapping past the edge-pinned dot. */}
<div className="ml-auto flex items-center gap-1 shrink-0">
{/* #10: normalized agent status — only for an external agent with a
live status frame. Distinct from the WS-liveness dot that follows. */}
{agentStatus && value.provider !== 'boocode' && (
<AgentStatusDot entry={agentStatus} agent={value.provider} />
)}
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}