diff --git a/web/src/canvas/SessionCanvas.tsx b/web/src/canvas/SessionCanvas.tsx index 9648a4d..34f5476 100644 --- a/web/src/canvas/SessionCanvas.tsx +++ b/web/src/canvas/SessionCanvas.tsx @@ -1,8 +1,14 @@ +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 { ConfirmModal } from '@/modals/ConfirmModal'; +import { apiFetch } from '@/api/client'; +import { questionFromToolCall } from '@/lib/hitl/questionFromToolCall'; import type { ToolCall } from '@/api/types'; interface SessionCanvasProps { @@ -40,9 +46,23 @@ 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 [rejectOpen, setRejectOpen] = useState(false); + const [stopOpen, setStopOpen] = useState(false); + + const pending = useMemo(() => findPendingApproval(state.toolCalls), [state.toolCalls]); if (activeSid === null) { return ( @@ -105,6 +125,48 @@ 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(); + } + async function handleReject() { + if (!pending) return; + await apiFetch(`/sessions/${sessId}/approvals/${pending.idx}`, { + method: 'POST', + json: { decision: 'reject', approver: 'operator', rationale: null }, + }); + refresh(); + } + async function handleStop() { + await apiFetch(`/sessions/${sessId}`, { method: 'DELETE' }); + refresh(); + } + return (
{ /* Phase 6: confirm modal */ }} + onStop={() => setStopOpen(true)} onRetry={refresh} /> { /* Phase 6 */ }} - onReject={() => { /* Phase 6 */ }} - onApproveWithRationale={() => { /* Phase 6 */ }} + onApprove={() => { void handleApprove(); }} + onReject={() => { if (pending) setRejectOpen(true); }} + onApproveWithRationale={() => { if (pending) setRationaleOpen(true); }} + /> + {pending && ( + <> + + + {pending.toolCall.tool} requested by{' '} + {pending.toolCall.agent} will be skipped and + the session resumes without it. + + } + confirmLabel="Reject" + destructive + onConfirm={handleReject} + /> + + )} +
); diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx index 994c2f3..7f6bdbd 100644 --- a/web/src/components/Modal.tsx +++ b/web/src/components/Modal.tsx @@ -5,6 +5,7 @@ interface PrimaryAction { label: string; onClick: () => void; disabled?: boolean; + destructive?: boolean; } interface ModalProps { @@ -169,15 +170,16 @@ export function Modal({ type="button" onClick={primaryAction.onClick} disabled={primaryAction.disabled} + data-destructive={primaryAction.destructive ? 'true' : undefined} style={{ height: 28, padding: '0 14px', fontFamily: 'var(--ff-sans)', fontSize: 'var(--t-body)', fontWeight: 500, - color: 'var(--bg-elev)', - background: 'var(--ink-1)', - border: '1px solid var(--ink-1)', + color: primaryAction.destructive ? 'var(--bg-elev)' : 'var(--bg-elev)', + background: primaryAction.destructive ? 'var(--danger)' : 'var(--ink-1)', + border: `1px solid ${primaryAction.destructive ? 'var(--danger)' : 'var(--ink-1)'}`, borderRadius: 0, cursor: primaryAction.disabled ? 'not-allowed' : 'pointer', opacity: primaryAction.disabled ? 0.5 : 1, 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, + }} + > +
+ +