Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions web/src/canvas/Turn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { CSSProperties } from 'react';
import type { ToolCall } from '@/api/types';
import { Sidenote } from './Sidenote';

interface TurnProps {
agent: string;
timestamp: string; // ISO UTC
elapsedMs: number | null;
body: string;
confidence: number | null;
model: string;
durationMs: number;
turn: number;
toolCalls: ToolCall[];
active: boolean;
onSelectTool: (tc: ToolCall) => void;
}

const grid: CSSProperties = {
display: 'grid',
gridTemplateColumns: '96px 1fr 240px',
gap: 24,
padding: '16px 24px',
borderBottom: '1px solid var(--hair)',
transition: 'background-color 0.12s',
};

const bylineStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 2,
fontFamily: 'var(--ff-mono)',
};

function fmtTime(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toISOString().slice(11, 19);
}

function fmtElapsed(ms: number | null): string | null {
if (ms === null || ms === undefined) return null;
if (ms === 0) return '+0s';
const sec = ms / 1000;
if (sec < 60) return `+${sec.toFixed(1)}s`;
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return `+${m}m${s}s`;
}

export function Turn(props: TurnProps) {
const elapsedStr = fmtElapsed(props.elapsedMs);
return (
<div
data-turn={props.turn}
data-active={props.active}
style={{
...grid,
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--bg-tint)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<div style={bylineStyle}>
<span
style={{
fontSize: 12,
fontWeight: 500,
color: props.active ? 'var(--acc)' : 'var(--ink-1)',
}}
>
{props.agent}
</span>
<span style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{fmtTime(props.timestamp)}
</span>
{elapsedStr && (
<span style={{ fontSize: 10, color: 'var(--ink-4)' }}>
{elapsedStr}
</span>
)}
</div>
<div
style={{
fontFamily: 'var(--ff-sans)',
fontSize: 14,
lineHeight: 1.6,
color: props.active ? 'var(--ink-2)' : 'var(--ink-1)',
fontStyle: props.active ? 'italic' : 'normal',
}}
>
{props.body}
{props.active && (
<span
data-typing-cursor
aria-hidden
style={{
display: 'inline-block',
width: 7,
height: 14,
marginLeft: 4,
verticalAlign: -2,
background: 'var(--ink-2)',
animation: 'asr-typing 1.2s step-end infinite',
}}
/>
)}
</div>
<Sidenote
confidence={props.confidence}
model={props.model}
durationMs={props.durationMs}
turn={props.turn}
toolCalls={props.toolCalls}
onSelectTool={props.onSelectTool}
/>
</div>
);
}
5 changes: 5 additions & 0 deletions web/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,8 @@ body {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

@keyframes asr-typing {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
100 changes: 100 additions & 0 deletions web/tests/component/Turn.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '../_helpers/render';
import { Turn } from '@/canvas/Turn';
import type { ToolCall } from '@/api/types';

const tools: ToolCall[] = [];

describe('<Turn>', () => {
it('renders byline with agent name + timestamp', () => {
render(
<Turn
agent="intake" timestamp="2026-05-15T14:16:32Z" elapsedMs={2200}
body="Triage observed elevated p99 latency on payments-svc."
confidence={0.92} model="gpt" durationMs={1230} turn={1}
toolCalls={tools}
active={false}
onSelectTool={() => {}}
/>,
);
expect(screen.getByText('intake')).toBeInTheDocument();
expect(screen.getByText(/14:16:32/)).toBeInTheDocument();
expect(screen.getByText(/Triage observed/)).toBeInTheDocument();
});

it('renders elapsed delta in mono when provided', () => {
render(
<Turn
agent="x" timestamp="2026-05-15T14:16:32Z" elapsedMs={2200}
body="x" confidence={null} model="x" durationMs={0} turn={1}
toolCalls={tools} active={false}
onSelectTool={() => {}}
/>,
);
expect(screen.getByText(/\+2\.2s/)).toBeInTheDocument();
});

it('marks active turn with data-active="true" and shows typing cursor in body', () => {
const { container } = render(
<Turn
agent="investigate" timestamp="2026-05-15T14:17:30Z" elapsedMs={null}
body="Reading deploy diff..." confidence={null} model="x" durationMs={0} turn={2}
toolCalls={tools} active={true}
onSelectTool={() => {}}
/>,
);
expect(container.firstChild).toHaveAttribute('data-active', 'true');
expect(container.querySelector('[data-typing-cursor]')).not.toBeNull();
});

it('non-active turn omits typing cursor', () => {
const { container } = render(
<Turn
agent="x" timestamp="2026-05-15T14:16:00Z" elapsedMs={0}
body="done" confidence={null} model="x" durationMs={0} turn={1}
toolCalls={tools} active={false}
onSelectTool={() => {}}
/>,
);
expect(container.querySelector('[data-typing-cursor]')).toBeNull();
expect(container.firstChild).toHaveAttribute('data-active', 'false');
});

it('passes toolCalls through to Sidenote and renders them', () => {
const tc: ToolCall = {
agent: 'x', tool: 'obs:get_logs', args: {}, result: null,
ts: '2026-05-15T14:16:50Z', risk: 'low',
status: 'executed', approver: null, approved_at: null, approval_rationale: null,
};
render(
<Turn
agent="x" timestamp="2026-05-15T14:16:32Z" elapsedMs={0}
body="x" confidence={null} model="x" durationMs={0} turn={1}
toolCalls={[tc]} active={false}
onSelectTool={() => {}}
/>,
);
expect(screen.getByText('obs:get_logs')).toBeInTheDocument();
});

it('forwards onSelectTool to Sidenote', () => {
const onSelectTool = vi.fn();
const tc: ToolCall = {
agent: 'x', tool: 'obs:get_logs', args: {}, result: null,
ts: 'x', risk: null, status: 'executed',
approver: null, approved_at: null, approval_rationale: null,
};
render(
<Turn
agent="x" timestamp="2026-05-15T14:16:32Z" elapsedMs={0}
body="x" confidence={null} model="x" durationMs={0} turn={1}
toolCalls={[tc]} active={false}
onSelectTool={onSelectTool}
/>,
);
screen.getByText('obs:get_logs').closest('[data-tool-card]')!.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
expect(onSelectTool).toHaveBeenCalledWith(tc);
});
});
Loading