From 923b94302c775127a5a903e6bde80c65bfa8bb07 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 09:05:33 +0000 Subject: [PATCH 1/4] fix(web): exclude e2e specs from vitest + default E2E base to localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/vitest.config.ts: add tests/e2e/** to exclude list so vitest no longer tries to evaluate the Playwright spec (was failing with "two different versions of @playwright/test" error). - web/tests/e2e/new-session.live.spec.ts: default E2E_BASE_URL to http://localhost:5173 (was a remote deploy URL) so the loop and CI can run against locally-served stack. Verified: npm run test:unit → 39 files, 156 passed. --- web/tests/e2e/new-session.live.spec.ts | 2 +- web/vitest.config.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/tests/e2e/new-session.live.spec.ts b/web/tests/e2e/new-session.live.spec.ts index 3987f33..864d0fa 100644 --- a/web/tests/e2e/new-session.live.spec.ts +++ b/web/tests/e2e/new-session.live.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; -const BASE = process.env.E2E_BASE_URL ?? 'https://clm.randomcodespace.dev'; +const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; test('shell renders + New Session creates a INC- id and opens canvas', async ({ page }) => { test.setTimeout(60_000); diff --git a/web/vitest.config.ts b/web/vitest.config.ts index b1e5163..ef60235 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,5 +1,5 @@ // web/vitest.config.ts -import { defineConfig } from 'vitest/config'; +import { defineConfig, configDefaults } from 'vitest/config'; import react from '@vitejs/plugin-react'; import { fileURLToPath } from 'node:url'; @@ -15,6 +15,7 @@ export default defineConfig({ setupFiles: ['./tests/setup.ts'], globals: true, css: false, + exclude: [...configDefaults.exclude, 'tests/e2e/**'], coverage: { provider: 'v8', reporter: ['text', 'lcov'], From 6e96ce26505741431830a441988a4e6633ecbf55 Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Sat, 16 May 2026 09:11:19 +0000 Subject: [PATCH 2/4] feat(web): Task 52 ApproveRationaleModal + SessionCanvas HITL wiring - web/src/modals/ApproveRationaleModal.tsx: Modal with rationale textarea + optional templates row + error envelope; POSTs to /api/v1/sessions/{sid}/approvals/{tool_call_id} with { decision: 'approve', approver, rationale }. - web/src/canvas/SessionCanvas.tsx: derives hitlContext from state.toolCalls (finds first pending_approval); wires plain onApprove (direct POST, rationale=null), onApproveWithRationale (opens modal). Reject + Stop still no-ops, picked up by Task 53. - web/tests/component/ApproveRationaleModal.test.tsx: 6 tests covering disabled state, submit, template chips, error envelope. Verified: typecheck clean; vitest 40 files / 162 tests pass; build 308 kB / 94 kB gzip. --- web/src/canvas/SessionCanvas.tsx | 69 +++++++- web/src/modals/ApproveRationaleModal.tsx | 163 ++++++++++++++++++ .../component/ApproveRationaleModal.test.tsx | 115 ++++++++++++ 3 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 web/src/modals/ApproveRationaleModal.tsx create mode 100644 web/tests/component/ApproveRationaleModal.test.tsx diff --git a/web/src/canvas/SessionCanvas.tsx b/web/src/canvas/SessionCanvas.tsx index 9648a4d..7b6ffe3 100644 --- a/web/src/canvas/SessionCanvas.tsx +++ b/web/src/canvas/SessionCanvas.tsx @@ -1,8 +1,13 @@ +import { useState, useMemo } from 'react'; import type { CSSProperties } from 'react'; import { useSessionFull } from '@/state/useSessionFull'; import { useSetSelected } from '@/state/selectedRef'; +import { useUiHints } from '@/state/useUiHints'; import { CanvasHead } from './CanvasHead'; -import { Transcript } from './Transcript'; +import { Transcript, type HITLContext } from './Transcript'; +import { ApproveRationaleModal } from '@/modals/ApproveRationaleModal'; +import { apiFetch } from '@/api/client'; +import { questionFromToolCall } from '@/lib/hitl/questionFromToolCall'; import type { ToolCall } from '@/api/types'; interface SessionCanvasProps { @@ -40,9 +45,21 @@ function extraNum(extras: Record, key: string, fallback: number return fallback; } +function findPendingApproval(toolCalls: ToolCall[]): { toolCall: ToolCall; idx: number } | null { + for (let i = 0; i < toolCalls.length; i++) { + const tc = toolCalls[i]; + if (tc && tc.status === 'pending_approval') return { toolCall: tc, idx: i }; + } + return null; +} + export function SessionCanvas({ activeSid }: SessionCanvasProps) { const { state, isLoading, error, refresh } = useSessionFull(activeSid); const setSelected = useSetSelected(); + const uiHints = useUiHints(); + const [rationaleOpen, setRationaleOpen] = useState(false); + + const pending = useMemo(() => findPendingApproval(state.toolCalls), [state.toolCalls]); if (activeSid === null) { return ( @@ -105,6 +122,36 @@ export function SessionCanvas({ activeSid }: SessionCanvasProps) { setSelected({ kind: 'tool_call', id: `${tc.tool}@${tc.ts}` }); }; + const hitlContext: HITLContext | null = pending + ? { + toolCall: pending.toolCall, + waitedSeconds: Math.max( + 0, + Math.round((Date.now() - new Date(pending.toolCall.ts).getTime()) / 1000), + ), + question: questionFromToolCall( + pending.toolCall, + uiHints.data?.hitl_question_templates ?? {}, + ), + confidence: state.agentsRun.at(-1)?.confidence ?? null, + turn: state.agentsRun.length || 1, + requestedBy: pending.toolCall.agent, + policy: pending.toolCall.risk + ? `risk=${pending.toolCall.risk}` + : 'risk=unknown', + } + : null; + + const sessId = sess.id; + async function handleApprove() { + if (!pending) return; + await apiFetch(`/sessions/${sessId}/approvals/${pending.idx}`, { + method: 'POST', + json: { decision: 'approve', approver: 'operator', rationale: null }, + }); + refresh(); + } + return (
{ /* Phase 6: confirm modal */ }} + onStop={() => { /* Phase 6 / Task 53 confirm modal */ }} onRetry={refresh} /> { /* Phase 6 */ }} - onReject={() => { /* Phase 6 */ }} - onApproveWithRationale={() => { /* Phase 6 */ }} + onApprove={() => { void handleApprove(); }} + onReject={() => { /* Task 53: confirm modal */ }} + onApproveWithRationale={() => { if (pending) setRationaleOpen(true); }} /> + {pending && ( + + )}
); } diff --git a/web/src/modals/ApproveRationaleModal.tsx b/web/src/modals/ApproveRationaleModal.tsx new file mode 100644 index 0000000..120f0ca --- /dev/null +++ b/web/src/modals/ApproveRationaleModal.tsx @@ -0,0 +1,163 @@ +import { useState, useEffect } from 'react'; +import type { CSSProperties } from 'react'; +import { Modal } from '@/components/Modal'; +import { apiFetch, ApiClientError } from '@/api/client'; + +interface ApproveRationaleModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + sessionId: string; + toolCallId: string; + approver?: string; + templates?: string[]; + onApproved: () => void; +} + +const labelStyle: CSSProperties = { + display: 'block', + fontFamily: 'var(--ff-mono)', + fontSize: 10, + color: 'var(--ink-3)', + letterSpacing: '0.14em', + textTransform: 'uppercase', + marginBottom: 4, +}; + +const fieldWrap: CSSProperties = { marginBottom: 16 }; + +const inputStyle: CSSProperties = { + width: '100%', + height: 32, + padding: '0 10px', + fontFamily: 'var(--ff-sans)', + fontSize: 13, + color: 'var(--ink-1)', + background: 'var(--bg-elev)', + border: '1px solid var(--hair)', + borderRadius: 0, + outline: 'none', + boxSizing: 'border-box', +}; + +const textareaStyle: CSSProperties = { + ...inputStyle, + height: 'auto', + minHeight: 96, + padding: '8px 10px', + resize: 'vertical', + lineHeight: 1.5, +}; + +const templateChip: CSSProperties = { + padding: '4px 8px', + fontFamily: 'var(--ff-mono)', + fontSize: 11, + color: 'var(--ink-2)', + background: 'var(--bg-subtle)', + border: '1px solid var(--hair)', + borderRadius: 0, + cursor: 'pointer', +}; + +export function ApproveRationaleModal({ + open, + onOpenChange, + sessionId, + toolCallId, + approver = 'operator', + templates = [], + onApproved, +}: ApproveRationaleModalProps) { + const [rationale, setRationale] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) { + setRationale(''); + setError(null); + setSubmitting(false); + } + }, [open]); + + const canSubmit = rationale.trim().length > 0 && !submitting; + + async function handleSubmit() { + if (!canSubmit) return; + setSubmitting(true); + setError(null); + try { + await apiFetch(`/sessions/${sessionId}/approvals/${toolCallId}`, { + method: 'POST', + json: { decision: 'approve', approver, rationale: rationale.trim() }, + }); + onApproved(); + onOpenChange(false); + } catch (e) { + if (e instanceof ApiClientError) { + setError(`${e.code}: ${e.message}`); + } else { + setError(String(e)); + } + setSubmitting(false); + } + } + + return ( + { void handleSubmit(); }, + disabled: !canSubmit, + }} + > +
+ +