From e34853579dc11b9cec659cfbbd96dec3b66a3dae Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 05:40:35 +0000 Subject: [PATCH] feat(web): component (3-col editorial layout with active typing cursor) --- web/src/canvas/Turn.tsx | 124 ++++++++++++++++++++++++++++++ web/src/styles/global.css | 5 ++ web/tests/component/Turn.test.tsx | 100 ++++++++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 web/src/canvas/Turn.tsx create mode 100644 web/tests/component/Turn.test.tsx 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); + }); +});