diff --git a/web/src/canvas/Transcript.tsx b/web/src/canvas/Transcript.tsx new file mode 100644 index 0000000..b9c9126 --- /dev/null +++ b/web/src/canvas/Transcript.tsx @@ -0,0 +1,126 @@ +import type { CSSProperties } from 'react'; +import type { AgentRun, ToolCall } from '@/api/types'; +import { Turn } from './Turn'; +import { HITLBand } from './HITLBand'; + +export interface ActiveAgentSnapshot { + name: string; + startedAt: string; + currentBody: string; +} + +export interface HITLContext { + toolCall: ToolCall; + waitedSeconds: number; + question: string; + confidence: number | null; + turn: number; + requestedBy: string; + policy: string; +} + +interface TranscriptProps { + agentsRun: AgentRun[]; + toolCalls: ToolCall[]; + activeAgent: ActiveAgentSnapshot | null; + hitlContext: HITLContext | null; + onSelectTool: (tc: ToolCall) => void; + onApprove: () => void; + onReject: () => void; + onApproveWithRationale: () => void; +} + +const emptyStyle: CSSProperties = { + padding: 40, + textAlign: 'center', + fontFamily: 'var(--ff-sans)', + fontSize: 13, + color: 'var(--ink-3)', +}; + +function durationMs(startIso: string, endIso: string): number { + const s = new Date(startIso).getTime(); + const e = new Date(endIso).getTime(); + if (isNaN(s) || isNaN(e)) return 0; + return Math.max(0, e - s); +} + +function elapsedFromOpening(openingIso: string, atIso: string): number { + const o = new Date(openingIso).getTime(); + const a = new Date(atIso).getTime(); + if (isNaN(o) || isNaN(a)) return 0; + return Math.max(0, a - o); +} + +function groupToolsByAgent(toolCalls: ToolCall[]): Map { + const m = new Map(); + for (const tc of toolCalls) { + const arr = m.get(tc.agent) ?? []; + arr.push(tc); + m.set(tc.agent, arr); + } + return m; +} + +export function Transcript({ + agentsRun, toolCalls, activeAgent, hitlContext, + onSelectTool, onApprove, onReject, onApproveWithRationale, +}: TranscriptProps) { + const empty = agentsRun.length === 0 && activeAgent === null && hitlContext === null; + if (empty) { + return
No turns yet.
; + } + + const toolsByAgent = groupToolsByAgent(toolCalls); + const opening = agentsRun[0]?.started_at ?? activeAgent?.startedAt ?? ''; + + return ( +
+ {agentsRun.map((run, i) => ( + + ))} + {activeAgent && ( + + )} + {hitlContext && ( + + )} +
+ ); +} diff --git a/web/tests/component/Transcript.test.tsx b/web/tests/component/Transcript.test.tsx new file mode 100644 index 0000000..398ee7a --- /dev/null +++ b/web/tests/component/Transcript.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../_helpers/render'; +import { Transcript } from '@/canvas/Transcript'; +import type { AgentRun, ToolCall } from '@/api/types'; + +const intakeRun: AgentRun = { + agent: 'intake', started_at: '2026-05-15T14:16:30Z', ended_at: '2026-05-15T14:16:32Z', + summary: 'Triage observed elevated p99 latency.', confidence: 0.92, + confidence_rationale: null, signal: null, +}; +const triageRun: AgentRun = { + agent: 'triage', started_at: '2026-05-15T14:16:50Z', ended_at: '2026-05-15T14:16:54Z', + summary: 'Correlated with deploy.', confidence: 0.88, + confidence_rationale: null, signal: null, +}; +const tcLogs: ToolCall = { + agent: 'triage', tool: 'obs:get_logs', args: { service: 'api' }, result: { lines: 12 }, + ts: '2026-05-15T14:16:52Z', risk: 'low', status: 'executed', + approver: null, approved_at: null, approval_rationale: null, +}; +const tcPending: ToolCall = { + agent: 'investigate', tool: 'rem:restart_service', + args: { service: 'payments-svc' }, result: null, ts: '2026-05-15T14:18:00Z', + risk: 'high', status: 'pending_approval', + approver: null, approved_at: null, approval_rationale: null, +}; + +describe('', () => { + it('renders one Turn per completed agent run', () => { + render( + {}} + onApprove={() => {}} + onReject={() => {}} + onApproveWithRationale={() => {}} + />, + ); + expect(screen.getByText('intake')).toBeInTheDocument(); + expect(screen.getByText('triage')).toBeInTheDocument(); + }); + + it('renders active Turn for currently-running agent (not yet in agentsRun)', () => { + render( + {}} + onApprove={() => {}} + onReject={() => {}} + onApproveWithRationale={() => {}} + />, + ); + expect(screen.getByText('investigate')).toBeInTheDocument(); + expect(screen.getByText(/Reading deploy diff/)).toBeInTheDocument(); + }); + + it('groups tool calls by agent and renders them in the matching Turn sidenote', () => { + render( + {}} + onApprove={() => {}} + onReject={() => {}} + onApproveWithRationale={() => {}} + />, + ); + // tcLogs.agent === 'triage', so it appears in triage's sidenote + expect(screen.getByText('obs:get_logs')).toBeInTheDocument(); + }); + + it('renders HITLBand when hitlContext is provided', () => { + render( + {}} + onApprove={() => {}} + onReject={() => {}} + onApproveWithRationale={() => {}} + />, + ); + expect(screen.getByText(/APPROVAL/)).toBeInTheDocument(); + expect(screen.getByText('Restart payments-svc?')).toBeInTheDocument(); + }); + + it('renders empty state when there are no turns', () => { + render( + {}} + onApprove={() => {}} + onReject={() => {}} + onApproveWithRationale={() => {}} + />, + ); + expect(screen.getByText(/No turns yet/i)).toBeInTheDocument(); + }); +});