Skip to content

Commit cf21835

Browse files
authored
feat(web): React UI v2.0 — Phase 4 Task 39 (Turn)
1 parent 13d40b6 commit cf21835

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

web/src/canvas/Turn.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { CSSProperties } from 'react';
2+
import type { ToolCall } from '@/api/types';
3+
import { Sidenote } from './Sidenote';
4+
5+
interface TurnProps {
6+
agent: string;
7+
timestamp: string; // ISO UTC
8+
elapsedMs: number | null;
9+
body: string;
10+
confidence: number | null;
11+
model: string;
12+
durationMs: number;
13+
turn: number;
14+
toolCalls: ToolCall[];
15+
active: boolean;
16+
onSelectTool: (tc: ToolCall) => void;
17+
}
18+
19+
const grid: CSSProperties = {
20+
display: 'grid',
21+
gridTemplateColumns: '96px 1fr 240px',
22+
gap: 24,
23+
padding: '16px 24px',
24+
borderBottom: '1px solid var(--hair)',
25+
transition: 'background-color 0.12s',
26+
};
27+
28+
const bylineStyle: CSSProperties = {
29+
display: 'flex',
30+
flexDirection: 'column',
31+
alignItems: 'flex-end',
32+
gap: 2,
33+
fontFamily: 'var(--ff-mono)',
34+
};
35+
36+
function fmtTime(iso: string): string {
37+
const d = new Date(iso);
38+
if (isNaN(d.getTime())) return iso;
39+
return d.toISOString().slice(11, 19);
40+
}
41+
42+
function fmtElapsed(ms: number | null): string | null {
43+
if (ms === null || ms === undefined) return null;
44+
if (ms === 0) return '+0s';
45+
const sec = ms / 1000;
46+
if (sec < 60) return `+${sec.toFixed(1)}s`;
47+
const m = Math.floor(sec / 60);
48+
const s = Math.round(sec % 60);
49+
return `+${m}m${s}s`;
50+
}
51+
52+
export function Turn(props: TurnProps) {
53+
const elapsedStr = fmtElapsed(props.elapsedMs);
54+
return (
55+
<div
56+
data-turn={props.turn}
57+
data-active={props.active}
58+
style={{
59+
...grid,
60+
background: 'transparent',
61+
}}
62+
onMouseEnter={(e) => {
63+
e.currentTarget.style.background = 'var(--bg-tint)';
64+
}}
65+
onMouseLeave={(e) => {
66+
e.currentTarget.style.background = 'transparent';
67+
}}
68+
>
69+
<div style={bylineStyle}>
70+
<span
71+
style={{
72+
fontSize: 12,
73+
fontWeight: 500,
74+
color: props.active ? 'var(--acc)' : 'var(--ink-1)',
75+
}}
76+
>
77+
{props.agent}
78+
</span>
79+
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>
80+
{fmtTime(props.timestamp)}
81+
</span>
82+
{elapsedStr && (
83+
<span style={{ fontSize: 10, color: 'var(--ink-4)' }}>
84+
{elapsedStr}
85+
</span>
86+
)}
87+
</div>
88+
<div
89+
style={{
90+
fontFamily: 'var(--ff-sans)',
91+
fontSize: 14,
92+
lineHeight: 1.6,
93+
color: props.active ? 'var(--ink-2)' : 'var(--ink-1)',
94+
fontStyle: props.active ? 'italic' : 'normal',
95+
}}
96+
>
97+
{props.body}
98+
{props.active && (
99+
<span
100+
data-typing-cursor
101+
aria-hidden
102+
style={{
103+
display: 'inline-block',
104+
width: 7,
105+
height: 14,
106+
marginLeft: 4,
107+
verticalAlign: -2,
108+
background: 'var(--ink-2)',
109+
animation: 'asr-typing 1.2s step-end infinite',
110+
}}
111+
/>
112+
)}
113+
</div>
114+
<Sidenote
115+
confidence={props.confidence}
116+
model={props.model}
117+
durationMs={props.durationMs}
118+
turn={props.turn}
119+
toolCalls={props.toolCalls}
120+
onSelectTool={props.onSelectTool}
121+
/>
122+
</div>
123+
);
124+
}

web/src/styles/global.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,8 @@ body {
9999
0%, 100% { opacity: 1; }
100100
50% { opacity: 0.4; }
101101
}
102+
103+
@keyframes asr-typing {
104+
0%, 50% { opacity: 1; }
105+
51%, 100% { opacity: 0; }
106+
}

web/tests/component/Turn.test.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '../_helpers/render';
3+
import { Turn } from '@/canvas/Turn';
4+
import type { ToolCall } from '@/api/types';
5+
6+
const tools: ToolCall[] = [];
7+
8+
describe('<Turn>', () => {
9+
it('renders byline with agent name + timestamp', () => {
10+
render(
11+
<Turn
12+
agent="intake" timestamp="2026-05-15T14:16:32Z" elapsedMs={2200}
13+
body="Triage observed elevated p99 latency on payments-svc."
14+
confidence={0.92} model="gpt" durationMs={1230} turn={1}
15+
toolCalls={tools}
16+
active={false}
17+
onSelectTool={() => {}}
18+
/>,
19+
);
20+
expect(screen.getByText('intake')).toBeInTheDocument();
21+
expect(screen.getByText(/14:16:32/)).toBeInTheDocument();
22+
expect(screen.getByText(/Triage observed/)).toBeInTheDocument();
23+
});
24+
25+
it('renders elapsed delta in mono when provided', () => {
26+
render(
27+
<Turn
28+
agent="x" timestamp="2026-05-15T14:16:32Z" elapsedMs={2200}
29+
body="x" confidence={null} model="x" durationMs={0} turn={1}
30+
toolCalls={tools} active={false}
31+
onSelectTool={() => {}}
32+
/>,
33+
);
34+
expect(screen.getByText(/\+2\.2s/)).toBeInTheDocument();
35+
});
36+
37+
it('marks active turn with data-active="true" and shows typing cursor in body', () => {
38+
const { container } = render(
39+
<Turn
40+
agent="investigate" timestamp="2026-05-15T14:17:30Z" elapsedMs={null}
41+
body="Reading deploy diff..." confidence={null} model="x" durationMs={0} turn={2}
42+
toolCalls={tools} active={true}
43+
onSelectTool={() => {}}
44+
/>,
45+
);
46+
expect(container.firstChild).toHaveAttribute('data-active', 'true');
47+
expect(container.querySelector('[data-typing-cursor]')).not.toBeNull();
48+
});
49+
50+
it('non-active turn omits typing cursor', () => {
51+
const { container } = render(
52+
<Turn
53+
agent="x" timestamp="2026-05-15T14:16:00Z" elapsedMs={0}
54+
body="done" confidence={null} model="x" durationMs={0} turn={1}
55+
toolCalls={tools} active={false}
56+
onSelectTool={() => {}}
57+
/>,
58+
);
59+
expect(container.querySelector('[data-typing-cursor]')).toBeNull();
60+
expect(container.firstChild).toHaveAttribute('data-active', 'false');
61+
});
62+
63+
it('passes toolCalls through to Sidenote and renders them', () => {
64+
const tc: ToolCall = {
65+
agent: 'x', tool: 'obs:get_logs', args: {}, result: null,
66+
ts: '2026-05-15T14:16:50Z', risk: 'low',
67+
status: 'executed', approver: null, approved_at: null, approval_rationale: null,
68+
};
69+
render(
70+
<Turn
71+
agent="x" timestamp="2026-05-15T14:16:32Z" elapsedMs={0}
72+
body="x" confidence={null} model="x" durationMs={0} turn={1}
73+
toolCalls={[tc]} active={false}
74+
onSelectTool={() => {}}
75+
/>,
76+
);
77+
expect(screen.getByText('obs:get_logs')).toBeInTheDocument();
78+
});
79+
80+
it('forwards onSelectTool to Sidenote', () => {
81+
const onSelectTool = vi.fn();
82+
const tc: ToolCall = {
83+
agent: 'x', tool: 'obs:get_logs', args: {}, result: null,
84+
ts: 'x', risk: null, status: 'executed',
85+
approver: null, approved_at: null, approval_rationale: null,
86+
};
87+
render(
88+
<Turn
89+
agent="x" timestamp="2026-05-15T14:16:32Z" elapsedMs={0}
90+
body="x" confidence={null} model="x" durationMs={0} turn={1}
91+
toolCalls={[tc]} active={false}
92+
onSelectTool={onSelectTool}
93+
/>,
94+
);
95+
screen.getByText('obs:get_logs').closest('[data-tool-card]')!.dispatchEvent(
96+
new MouseEvent('click', { bubbles: true }),
97+
);
98+
expect(onSelectTool).toHaveBeenCalledWith(tc);
99+
});
100+
});

0 commit comments

Comments
 (0)