From 49d4c5927c314ac0f109ba61e17cc4e3e2b12bac Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 05:29:43 +0000 Subject: [PATCH 1/3] feat(web): component (eyebrow + title + meta row) --- web/src/canvas/CanvasHead.tsx | 117 ++++++++++++++++++++++++ web/src/styles/global.css | 5 + web/tests/component/CanvasHead.test.tsx | 90 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 web/src/canvas/CanvasHead.tsx create mode 100644 web/tests/component/CanvasHead.test.tsx diff --git a/web/src/canvas/CanvasHead.tsx b/web/src/canvas/CanvasHead.tsx new file mode 100644 index 0000000..d43465e --- /dev/null +++ b/web/src/canvas/CanvasHead.tsx @@ -0,0 +1,117 @@ +import type { CSSProperties } from 'react'; +import { Button } from '@/components/Button'; +import { Icon } from '@/icons/Icon'; + +interface CanvasHeadProps { + sessionId: string; + status: string; + openedAt: string; // ISO UTC + title: string; + env: string; + sev: number; + reporter: string; + turnCount: number; + toolCount: number; + agentsActive: number; + agentsTotal: number; + onStop: () => void; + onRetry: () => void; +} + +const wrap: CSSProperties = { + padding: '20px 24px 12px', + borderBottom: '1px solid var(--hair)', + background: 'var(--bg-elev)', +}; + +const labelStyle: CSSProperties = { + fontFamily: 'var(--ff-mono)', + fontSize: 10, + color: 'var(--ink-3)', + letterSpacing: '0.14em', + textTransform: 'uppercase', +}; + +function statusLabel(s: string): string { + return s.replace(/_/g, ' ').toUpperCase(); +} + +function fmtOpenedAt(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toISOString().slice(11, 19); +} + +function truncate(s: string, n: number): string { + if (s.length <= n) return s; + return s.slice(0, n) + '…'; +} + +export function CanvasHead(props: CanvasHeadProps) { + const truncTitle = truncate(props.title, 80); + const showRetry = props.status === 'error'; + const isActive = props.status === 'in_progress' || props.status === 'new'; + const eyebrowParts = [ + props.sessionId, + isActive ? 'ACTIVE' : statusLabel(props.status), + `OPENED ${fmtOpenedAt(props.openedAt)} UTC`, + ]; + return ( +
+
+ {isActive && ( + + )} + {eyebrowParts.join(' · ')} +
+

+ {truncTitle} +

+
+ ENV {props.env} + · + SEV {props.sev} + · + REPORTER {props.reporter} + · + TURNS {props.turnCount} + · + TOOLS {props.toolCount} + · + AGENTS {props.agentsActive} of {props.agentsTotal} + + + {showRetry && ( + + )} +
+
+ ); +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css index e3c67db..d2777e2 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -94,3 +94,8 @@ body { animation: none !important; } } + +@keyframes asr-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} diff --git a/web/tests/component/CanvasHead.test.tsx b/web/tests/component/CanvasHead.test.tsx new file mode 100644 index 0000000..da76e65 --- /dev/null +++ b/web/tests/component/CanvasHead.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { CanvasHead } from '@/canvas/CanvasHead'; + +describe('', () => { + it('renders eyebrow with session id, status, and opened timestamp', () => { + render( + {}} onRetry={() => {}} + />, + ); + expect(screen.getByText(/SES-20260515-042/)).toBeInTheDocument(); + expect(screen.getByText(/ACTIVE/i)).toBeInTheDocument(); + expect(screen.getByText(/Payments Service Latency Spike/)).toBeInTheDocument(); + }); + + it('renders meta row with env, sev, reporter, counts', () => { + render( + {}} onRetry={() => {}} + />, + ); + expect(screen.getByText(/ENV prod/)).toBeInTheDocument(); + expect(screen.getByText(/SEV 2/)).toBeInTheDocument(); + expect(screen.getByText(/TURNS 4/)).toBeInTheDocument(); + expect(screen.getByText(/TOOLS 12/)).toBeInTheDocument(); + expect(screen.getByText(/AGENTS 3 of 4/)).toBeInTheDocument(); + }); + + it('Stop button calls onStop', () => { + const onStop = vi.fn(); + render( + {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /Stop/i })); + expect(onStop).toHaveBeenCalledTimes(1); + }); + + it('Retry button only shown when status="error" and calls onRetry', () => { + const onRetry = vi.fn(); + const { rerender } = render( + {}} onRetry={onRetry} + />, + ); + expect(screen.queryByRole('button', { name: /Retry/i })).not.toBeInTheDocument(); + rerender( + {}} onRetry={onRetry} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /Retry/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('truncates titles longer than 80 chars with ellipsis', () => { + const long = 'a'.repeat(120); + render( + {}} onRetry={() => {}} + />, + ); + const title = screen.getByText(/a+/); + expect(title.textContent?.length).toBeLessThanOrEqual(81); // 80 + ellipsis + expect(title.textContent).toMatch(/…$/); + }); +}); From 8da41039e47dfe7ca510d8195f04ea1a5d4752d2 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 05:28:18 +0000 Subject: [PATCH 2/3] feat(web): + (turn right-column meta + tool cards) --- web/src/canvas/Sidenote.tsx | 56 +++++++++++++++++++++++ web/src/canvas/ToolCallCard.tsx | 65 +++++++++++++++++++++++++++ web/tests/component/Sidenote.test.tsx | 65 +++++++++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 web/src/canvas/Sidenote.tsx create mode 100644 web/src/canvas/ToolCallCard.tsx create mode 100644 web/tests/component/Sidenote.test.tsx diff --git a/web/src/canvas/Sidenote.tsx b/web/src/canvas/Sidenote.tsx new file mode 100644 index 0000000..568989b --- /dev/null +++ b/web/src/canvas/Sidenote.tsx @@ -0,0 +1,56 @@ +import type { CSSProperties } from 'react'; +import type { ToolCall } from '@/api/types'; +import { ToolCallCard } from './ToolCallCard'; + +interface SidenoteProps { + confidence: number | null; + model: string; + durationMs: number; + turn: number; + toolCalls: ToolCall[]; + onSelectTool: (tc: ToolCall) => void; +} + +const wrap: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + fontFamily: 'var(--ff-mono)', + fontSize: 11, + color: 'var(--ink-3)', +}; + +function Kv({ k, v }: { k: string; v: string | number | null }) { + return ( +
+ {k} + + {v === null ? '—' : String(v)} + +
+ ); +} + +export function Sidenote({ + confidence, model, durationMs, turn, toolCalls, onSelectTool, +}: SidenoteProps) { + return ( +
+ + + + + {toolCalls.length > 0 && ( +
+ {toolCalls.map((tc, i) => ( + onSelectTool(tc)} + /> + ))} +
+ )} +
+ ); +} diff --git a/web/src/canvas/ToolCallCard.tsx b/web/src/canvas/ToolCallCard.tsx new file mode 100644 index 0000000..9601969 --- /dev/null +++ b/web/src/canvas/ToolCallCard.tsx @@ -0,0 +1,65 @@ +import type { CSSProperties } from 'react'; +import type { ToolCall } from '@/api/types'; + +interface ToolCallCardProps { + tc: ToolCall; + onClick: () => void; +} + +const cardStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 2, + padding: '6px 8px', + border: '1px solid var(--hair)', + background: 'var(--bg-elev)', + cursor: 'pointer', + fontFamily: 'var(--ff-mono)', +}; + +const statusColor: Record = { + executed: 'var(--good)', + executed_with_notify: 'var(--good)', + pending_approval: 'var(--warn)', + approved: 'var(--good)', + rejected: 'var(--danger)', + auto_rejected: 'var(--danger)', + timeout: 'var(--danger)', +}; + +function shortStatus(s: string): string { + switch (s) { + case 'executed': return 'OK'; + case 'executed_with_notify': return 'OK*'; + case 'pending_approval': return 'PENDING'; + case 'approved': return 'APPROVED'; + case 'rejected': return 'REJECTED'; + case 'auto_rejected': return 'AUTO-REJ'; + case 'timeout': return 'TIMEOUT'; + default: return s.toUpperCase(); + } +} + +export function ToolCallCard({ tc, onClick }: ToolCallCardProps) { + return ( +
+
+ {tc.tool} + + {shortStatus(tc.status)} + +
+ {tc.risk && ( + + risk: {tc.risk} + + )} +
+ ); +} diff --git a/web/tests/component/Sidenote.test.tsx b/web/tests/component/Sidenote.test.tsx new file mode 100644 index 0000000..2afa43e --- /dev/null +++ b/web/tests/component/Sidenote.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { Sidenote } from '@/canvas/Sidenote'; +import type { ToolCall } from '@/api/types'; + +const tool1: ToolCall = { + agent: 'triage', tool: 'obs:get_logs', args: { service: 'api' }, + result: { lines: 12 }, ts: '2026-05-15T14:16:50Z', risk: 'low', + status: 'executed', approver: null, approved_at: null, approval_rationale: null, +}; + +describe('', () => { + it('renders confidence / model / duration k/v rows', () => { + render( + {}} + />, + ); + expect(screen.getByText(/conf/i)).toBeInTheDocument(); + expect(screen.getByText(/0\.92/)).toBeInTheDocument(); + expect(screen.getByText(/claude-sonnet-4-6/)).toBeInTheDocument(); + expect(screen.getByText(/1230ms/)).toBeInTheDocument(); + }); + + it('renders one tool-call mini-card per ToolCall', () => { + render( + {}} + />, + ); + expect(screen.getByText('obs:get_logs')).toBeInTheDocument(); + expect(screen.getByText('rem:propose_fix')).toBeInTheDocument(); + }); + + it('calls onSelectTool when a tool card is clicked', () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('obs:get_logs').closest('[data-tool-card]')!); + expect(onSelect).toHaveBeenCalledWith(tool1); + }); + + it('shows status pill on tool card', () => { + render( + {}} + />, + ); + expect(screen.getByText(/PENDING/i)).toBeInTheDocument(); + }); +}); From 461d3bc5f19f0c0037245015ea882aa9091462cd Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 05:30:18 +0000 Subject: [PATCH 3/3] feat(web): + questionFromToolCall lib (inline approval prompt) --- web/src/canvas/HITLBand.tsx | 106 ++++++++++++++++++++ web/src/lib/hitl/questionFromToolCall.ts | 24 +++++ web/tests/component/HITLBand.test.tsx | 93 +++++++++++++++++ web/tests/unit/questionFromToolCall.test.ts | 34 +++++++ 4 files changed, 257 insertions(+) create mode 100644 web/src/canvas/HITLBand.tsx create mode 100644 web/src/lib/hitl/questionFromToolCall.ts create mode 100644 web/tests/component/HITLBand.test.tsx create mode 100644 web/tests/unit/questionFromToolCall.test.ts diff --git a/web/src/canvas/HITLBand.tsx b/web/src/canvas/HITLBand.tsx new file mode 100644 index 0000000..35b47f2 --- /dev/null +++ b/web/src/canvas/HITLBand.tsx @@ -0,0 +1,106 @@ +import type { CSSProperties } from 'react'; +import type { ToolCall } from '@/api/types'; +import { Button } from '@/components/Button'; + +interface HITLBandProps { + toolCall: ToolCall; + waitedSeconds: number; + question: string; + confidence: number | null; + turn: number; + requestedBy: string; + policy: string; + onApprove: () => void; + onReject: () => void; + onApproveWithRationale: () => void; +} + +const grid: CSSProperties = { + display: 'grid', + gridTemplateColumns: '96px 1fr 240px', + gap: 24, + padding: '20px 24px', + background: 'linear-gradient(90deg, var(--warn-bg) 0%, rgba(180, 129, 74, 0.02) 100%)', + borderTop: '1px solid var(--warn)', + borderBottom: '1px solid var(--warn)', +}; + +const riskColor: Record = { + low: 'var(--good)', + medium: 'var(--warn)', + high: 'var(--danger)', +}; + +function Kv({ k, v, color }: { k: string; v: string | number; color?: string }) { + return ( +
+ {k} + {String(v)} +
+ ); +} + +export function HITLBand({ + toolCall, waitedSeconds, question, confidence, turn, + requestedBy, policy, onApprove, onReject, onApproveWithRationale, +}: HITLBandProps) { + const argsStr = JSON.stringify(toolCall.args) + .replace(/^\{|\}$/g, '') + .replace(/"/g, ''); + return ( +
+
+
+ APPROVAL +
+
+ waited {waitedSeconds} s +
+
+
+

+ {question} +

+
+ {toolCall.tool}({argsStr}) +
+
+ + + + +
+
+
+ + + + + +
+
+ ); +} diff --git a/web/src/lib/hitl/questionFromToolCall.ts b/web/src/lib/hitl/questionFromToolCall.ts new file mode 100644 index 0000000..2f59efc --- /dev/null +++ b/web/src/lib/hitl/questionFromToolCall.ts @@ -0,0 +1,24 @@ +import type { ToolCall } from '@/api/types'; + +const GENERIC = 'Allow {agent} to call {tool} (risk: {risk})?'; + +export function questionFromToolCall( + tc: ToolCall, + templates: Record, +): string { + const template = templates[tc.tool] ?? GENERIC; + return interpolate(template, { + agent: tc.agent, + tool: tc.tool, + risk: tc.risk ?? 'unknown', + ...(tc.args as Record), + }); +} + +function interpolate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => { + const v = vars[key]; + if (v === undefined || v === null) return match; + return String(v); + }); +} diff --git a/web/tests/component/HITLBand.test.tsx b/web/tests/component/HITLBand.test.tsx new file mode 100644 index 0000000..0250608 --- /dev/null +++ b/web/tests/component/HITLBand.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../_helpers/render'; +import { HITLBand } from '@/canvas/HITLBand'; +import type { ToolCall } from '@/api/types'; + +const tc: ToolCall = { + agent: 'investigate', tool: 'rem:restart_service', + args: { service: 'payments-svc' }, + result: null, ts: '2026-05-15T14:18:00Z', risk: 'high', + status: 'pending_approval', approver: null, approved_at: null, approval_rationale: null, +}; + +describe('', () => { + it('renders the APPROVAL byline + waited duration', () => { + render( + {}} + onReject={() => {}} + onApproveWithRationale={() => {}} + />, + ); + expect(screen.getByText(/APPROVAL/)).toBeInTheDocument(); + expect(screen.getByText(/18 s/)).toBeInTheDocument(); + }); + + it('renders the question prominently', () => { + render( + {}} onReject={() => {}} onApproveWithRationale={() => {}} + />, + ); + expect(screen.getByText('Restart payments-svc?')).toBeInTheDocument(); + }); + + it('renders the tool line with args', () => { + render( + {}} onReject={() => {}} onApproveWithRationale={() => {}} + />, + ); + expect(screen.getByText(/rem:restart_service/)).toBeInTheDocument(); + }); + + it('fires onApprove on Approve click', () => { + const onApprove = vi.fn(); + render( + {}} onApproveWithRationale={() => {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /^Approve$/i })); + expect(onApprove).toHaveBeenCalledTimes(1); + }); + + it('fires onReject on Reject click', () => { + const onReject = vi.fn(); + render( + {}} onReject={onReject} onApproveWithRationale={() => {}} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /Reject/i })); + expect(onReject).toHaveBeenCalledTimes(1); + }); + + it('fires onApproveWithRationale on rationale click', () => { + const onRat = vi.fn(); + render( + {}} onReject={() => {}} onApproveWithRationale={onRat} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /Approve with Rationale/i })); + expect(onRat).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/tests/unit/questionFromToolCall.test.ts b/web/tests/unit/questionFromToolCall.test.ts new file mode 100644 index 0000000..1b3dc84 --- /dev/null +++ b/web/tests/unit/questionFromToolCall.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { questionFromToolCall } from '@/lib/hitl/questionFromToolCall'; +import type { ToolCall } from '@/api/types'; + +const baseTc: ToolCall = { + agent: 'investigate', tool: 'rem:restart_service', + args: { service: 'payments-svc', environment: 'production' }, + result: null, ts: '2026-05-15T14:18:00Z', risk: 'high', + status: 'pending_approval', approver: null, approved_at: null, approval_rationale: null, +}; + +describe('questionFromToolCall', () => { + it('interpolates app-provided template with args + agent', () => { + const q = questionFromToolCall(baseTc, { + 'rem:restart_service': 'Restart {service} in {environment}?', + }); + expect(q).toBe('Restart payments-svc in production?'); + }); + + it('falls back to the generic template when no app override matches', () => { + const q = questionFromToolCall(baseTc, {}); + expect(q).toMatch(/investigate/); + expect(q).toMatch(/rem:restart_service/); + }); + + it('handles missing args gracefully', () => { + const tc: ToolCall = { ...baseTc, args: {} }; + const q = questionFromToolCall(tc, { + 'rem:restart_service': 'Restart {service}?', + }); + // missing arg interpolation keeps the placeholder visible — UI should surface this + expect(q).toContain('{service}'); + }); +});