diff --git a/web/src/canvas/Turn.tsx b/web/src/canvas/Turn.tsx
new file mode 100644
index 0000000..cdfe91f
--- /dev/null
+++ b/web/src/canvas/Turn.tsx
@@ -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 (
+
{
+ e.currentTarget.style.background = 'var(--bg-tint)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = 'transparent';
+ }}
+ >
+
+
+ {props.agent}
+
+
+ {fmtTime(props.timestamp)}
+
+ {elapsedStr && (
+
+ {elapsedStr}
+
+ )}
+
+
+ {props.body}
+ {props.active && (
+
+ )}
+
+
+
+ );
+}
diff --git a/web/src/styles/global.css b/web/src/styles/global.css
index d2777e2..7a7e5d4 100644
--- a/web/src/styles/global.css
+++ b/web/src/styles/global.css
@@ -99,3 +99,8 @@ body {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
+
+@keyframes asr-typing {
+ 0%, 50% { opacity: 1; }
+ 51%, 100% { opacity: 0; }
+}
diff --git a/web/tests/component/Turn.test.tsx b/web/tests/component/Turn.test.tsx
new file mode 100644
index 0000000..8de716b
--- /dev/null
+++ b/web/tests/component/Turn.test.tsx
@@ -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('', () => {
+ it('renders byline with agent name + timestamp', () => {
+ render(
+ {}}
+ />,
+ );
+ 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(
+ {}}
+ />,
+ );
+ expect(screen.getByText(/\+2\.2s/)).toBeInTheDocument();
+ });
+
+ it('marks active turn with data-active="true" and shows typing cursor in body', () => {
+ const { container } = render(
+ {}}
+ />,
+ );
+ 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(
+ {}}
+ />,
+ );
+ 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(
+ {}}
+ />,
+ );
+ 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(
+ ,
+ );
+ screen.getByText('obs:get_logs').closest('[data-tool-card]')!.dispatchEvent(
+ new MouseEvent('click', { bubbles: true }),
+ );
+ expect(onSelectTool).toHaveBeenCalledWith(tc);
+ });
+});