Skip to content

Commit ee4b429

Browse files
authored
feat(web): React UI v2.0 — Phase 4 Task 41 (Transcript assembly)
1 parent cf21835 commit ee4b429

2 files changed

Lines changed: 246 additions & 0 deletions

File tree

web/src/canvas/Transcript.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { CSSProperties } from 'react';
2+
import type { AgentRun, ToolCall } from '@/api/types';
3+
import { Turn } from './Turn';
4+
import { HITLBand } from './HITLBand';
5+
6+
export interface ActiveAgentSnapshot {
7+
name: string;
8+
startedAt: string;
9+
currentBody: string;
10+
}
11+
12+
export interface HITLContext {
13+
toolCall: ToolCall;
14+
waitedSeconds: number;
15+
question: string;
16+
confidence: number | null;
17+
turn: number;
18+
requestedBy: string;
19+
policy: string;
20+
}
21+
22+
interface TranscriptProps {
23+
agentsRun: AgentRun[];
24+
toolCalls: ToolCall[];
25+
activeAgent: ActiveAgentSnapshot | null;
26+
hitlContext: HITLContext | null;
27+
onSelectTool: (tc: ToolCall) => void;
28+
onApprove: () => void;
29+
onReject: () => void;
30+
onApproveWithRationale: () => void;
31+
}
32+
33+
const emptyStyle: CSSProperties = {
34+
padding: 40,
35+
textAlign: 'center',
36+
fontFamily: 'var(--ff-sans)',
37+
fontSize: 13,
38+
color: 'var(--ink-3)',
39+
};
40+
41+
function durationMs(startIso: string, endIso: string): number {
42+
const s = new Date(startIso).getTime();
43+
const e = new Date(endIso).getTime();
44+
if (isNaN(s) || isNaN(e)) return 0;
45+
return Math.max(0, e - s);
46+
}
47+
48+
function elapsedFromOpening(openingIso: string, atIso: string): number {
49+
const o = new Date(openingIso).getTime();
50+
const a = new Date(atIso).getTime();
51+
if (isNaN(o) || isNaN(a)) return 0;
52+
return Math.max(0, a - o);
53+
}
54+
55+
function groupToolsByAgent(toolCalls: ToolCall[]): Map<string, ToolCall[]> {
56+
const m = new Map<string, ToolCall[]>();
57+
for (const tc of toolCalls) {
58+
const arr = m.get(tc.agent) ?? [];
59+
arr.push(tc);
60+
m.set(tc.agent, arr);
61+
}
62+
return m;
63+
}
64+
65+
export function Transcript({
66+
agentsRun, toolCalls, activeAgent, hitlContext,
67+
onSelectTool, onApprove, onReject, onApproveWithRationale,
68+
}: TranscriptProps) {
69+
const empty = agentsRun.length === 0 && activeAgent === null && hitlContext === null;
70+
if (empty) {
71+
return <div style={emptyStyle}>No turns yet.</div>;
72+
}
73+
74+
const toolsByAgent = groupToolsByAgent(toolCalls);
75+
const opening = agentsRun[0]?.started_at ?? activeAgent?.startedAt ?? '';
76+
77+
return (
78+
<div style={{ display: 'flex', flexDirection: 'column' }}>
79+
{agentsRun.map((run, i) => (
80+
<Turn
81+
key={`${run.agent}-${run.started_at}-${i}`}
82+
agent={run.agent}
83+
timestamp={run.started_at}
84+
elapsedMs={elapsedFromOpening(opening, run.started_at)}
85+
body={run.summary}
86+
confidence={run.confidence}
87+
model={run.token_usage ? '—' : '—'}
88+
durationMs={durationMs(run.started_at, run.ended_at)}
89+
turn={i + 1}
90+
toolCalls={toolsByAgent.get(run.agent) ?? []}
91+
active={false}
92+
onSelectTool={onSelectTool}
93+
/>
94+
))}
95+
{activeAgent && (
96+
<Turn
97+
agent={activeAgent.name}
98+
timestamp={activeAgent.startedAt}
99+
elapsedMs={elapsedFromOpening(opening, activeAgent.startedAt)}
100+
body={activeAgent.currentBody}
101+
confidence={null}
102+
model="—"
103+
durationMs={0}
104+
turn={agentsRun.length + 1}
105+
toolCalls={toolsByAgent.get(activeAgent.name) ?? []}
106+
active={true}
107+
onSelectTool={onSelectTool}
108+
/>
109+
)}
110+
{hitlContext && (
111+
<HITLBand
112+
toolCall={hitlContext.toolCall}
113+
waitedSeconds={hitlContext.waitedSeconds}
114+
question={hitlContext.question}
115+
confidence={hitlContext.confidence}
116+
turn={hitlContext.turn}
117+
requestedBy={hitlContext.requestedBy}
118+
policy={hitlContext.policy}
119+
onApprove={onApprove}
120+
onReject={onReject}
121+
onApproveWithRationale={onApproveWithRationale}
122+
/>
123+
)}
124+
</div>
125+
);
126+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '../_helpers/render';
3+
import { Transcript } from '@/canvas/Transcript';
4+
import type { AgentRun, ToolCall } from '@/api/types';
5+
6+
const intakeRun: AgentRun = {
7+
agent: 'intake', started_at: '2026-05-15T14:16:30Z', ended_at: '2026-05-15T14:16:32Z',
8+
summary: 'Triage observed elevated p99 latency.', confidence: 0.92,
9+
confidence_rationale: null, signal: null,
10+
};
11+
const triageRun: AgentRun = {
12+
agent: 'triage', started_at: '2026-05-15T14:16:50Z', ended_at: '2026-05-15T14:16:54Z',
13+
summary: 'Correlated with deploy.', confidence: 0.88,
14+
confidence_rationale: null, signal: null,
15+
};
16+
const tcLogs: ToolCall = {
17+
agent: 'triage', tool: 'obs:get_logs', args: { service: 'api' }, result: { lines: 12 },
18+
ts: '2026-05-15T14:16:52Z', risk: 'low', status: 'executed',
19+
approver: null, approved_at: null, approval_rationale: null,
20+
};
21+
const tcPending: ToolCall = {
22+
agent: 'investigate', tool: 'rem:restart_service',
23+
args: { service: 'payments-svc' }, result: null, ts: '2026-05-15T14:18:00Z',
24+
risk: 'high', status: 'pending_approval',
25+
approver: null, approved_at: null, approval_rationale: null,
26+
};
27+
28+
describe('<Transcript>', () => {
29+
it('renders one Turn per completed agent run', () => {
30+
render(
31+
<Transcript
32+
agentsRun={[intakeRun, triageRun]}
33+
toolCalls={[]}
34+
activeAgent={null}
35+
hitlContext={null}
36+
onSelectTool={() => {}}
37+
onApprove={() => {}}
38+
onReject={() => {}}
39+
onApproveWithRationale={() => {}}
40+
/>,
41+
);
42+
expect(screen.getByText('intake')).toBeInTheDocument();
43+
expect(screen.getByText('triage')).toBeInTheDocument();
44+
});
45+
46+
it('renders active Turn for currently-running agent (not yet in agentsRun)', () => {
47+
render(
48+
<Transcript
49+
agentsRun={[intakeRun]}
50+
toolCalls={[]}
51+
activeAgent={{ name: 'investigate', startedAt: '2026-05-15T14:17:00Z', currentBody: 'Reading deploy diff...' }}
52+
hitlContext={null}
53+
onSelectTool={() => {}}
54+
onApprove={() => {}}
55+
onReject={() => {}}
56+
onApproveWithRationale={() => {}}
57+
/>,
58+
);
59+
expect(screen.getByText('investigate')).toBeInTheDocument();
60+
expect(screen.getByText(/Reading deploy diff/)).toBeInTheDocument();
61+
});
62+
63+
it('groups tool calls by agent and renders them in the matching Turn sidenote', () => {
64+
render(
65+
<Transcript
66+
agentsRun={[intakeRun, triageRun]}
67+
toolCalls={[tcLogs]}
68+
activeAgent={null}
69+
hitlContext={null}
70+
onSelectTool={() => {}}
71+
onApprove={() => {}}
72+
onReject={() => {}}
73+
onApproveWithRationale={() => {}}
74+
/>,
75+
);
76+
// tcLogs.agent === 'triage', so it appears in triage's sidenote
77+
expect(screen.getByText('obs:get_logs')).toBeInTheDocument();
78+
});
79+
80+
it('renders HITLBand when hitlContext is provided', () => {
81+
render(
82+
<Transcript
83+
agentsRun={[intakeRun]}
84+
toolCalls={[tcPending]}
85+
activeAgent={null}
86+
hitlContext={{
87+
toolCall: tcPending,
88+
waitedSeconds: 18,
89+
question: 'Restart payments-svc?',
90+
confidence: 0.78,
91+
turn: 3,
92+
requestedBy: 'u1',
93+
policy: 'risk:high requires approval',
94+
}}
95+
onSelectTool={() => {}}
96+
onApprove={() => {}}
97+
onReject={() => {}}
98+
onApproveWithRationale={() => {}}
99+
/>,
100+
);
101+
expect(screen.getByText(/APPROVAL/)).toBeInTheDocument();
102+
expect(screen.getByText('Restart payments-svc?')).toBeInTheDocument();
103+
});
104+
105+
it('renders empty state when there are no turns', () => {
106+
render(
107+
<Transcript
108+
agentsRun={[]}
109+
toolCalls={[]}
110+
activeAgent={null}
111+
hitlContext={null}
112+
onSelectTool={() => {}}
113+
onApprove={() => {}}
114+
onReject={() => {}}
115+
onApproveWithRationale={() => {}}
116+
/>,
117+
);
118+
expect(screen.getByText(/No turns yet/i)).toBeInTheDocument();
119+
});
120+
});

0 commit comments

Comments
 (0)