Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 106 additions & 6 deletions web/src/canvas/SessionCanvas.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -40,9 +46,23 @@ function extraNum(extras: Record<string, unknown>, 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 (
Expand Down Expand Up @@ -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 (
<div style={wrap}>
<CanvasHead
Expand All @@ -119,18 +181,56 @@ export function SessionCanvas({ activeSid }: SessionCanvasProps) {
toolCount={state.toolCalls.length}
agentsActive={state.agentsRun.length}
agentsTotal={Object.keys(state.agentDefinitions).length || state.agentsRun.length}
onStop={() => { /* Phase 6: confirm modal */ }}
onStop={() => setStopOpen(true)}
onRetry={refresh}
/>
<Transcript
agentsRun={state.agentsRun}
toolCalls={state.toolCalls}
activeAgent={null}
hitlContext={null}
hitlContext={hitlContext}
onSelectTool={onSelectTool}
onApprove={() => { /* Phase 6 */ }}
onReject={() => { /* Phase 6 */ }}
onApproveWithRationale={() => { /* Phase 6 */ }}
onApprove={() => { void handleApprove(); }}
onReject={() => { if (pending) setRejectOpen(true); }}
onApproveWithRationale={() => { if (pending) setRationaleOpen(true); }}
/>
{pending && (
<>
<ApproveRationaleModal
open={rationaleOpen}
onOpenChange={setRationaleOpen}
sessionId={sess.id}
toolCallId={String(pending.idx)}
templates={uiHints.data?.approval_rationale_templates ?? []}
onApproved={refresh}
/>
<ConfirmModal
open={rejectOpen}
onOpenChange={setRejectOpen}
eyebrow="REJECT TOOL CALL"
title="Reject this tool call?"
body={
<>
<code>{pending.toolCall.tool}</code> requested by{' '}
<code>{pending.toolCall.agent}</code> will be skipped and
the session resumes without it.
</>
}
confirmLabel="Reject"
destructive
onConfirm={handleReject}
/>
</>
)}
<ConfirmModal
open={stopOpen}
onOpenChange={setStopOpen}
eyebrow="STOP SESSION"
title="Stop this session?"
body="The session will be cancelled and any in-flight tool calls will be aborted. This cannot be undone."
confirmLabel="Stop session"
destructive
onConfirm={handleStop}
/>
</div>
);
Expand Down
8 changes: 5 additions & 3 deletions web/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface PrimaryAction {
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
}

interface ModalProps {
Expand Down Expand Up @@ -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,
Expand Down
163 changes: 163 additions & 0 deletions web/src/modals/ApproveRationaleModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<Modal
open={open}
onOpenChange={onOpenChange}
eyebrow="APPROVE WITH RATIONALE"
title="Approve this tool call"
primaryAction={{
label: submitting ? 'Approving…' : 'Approve',
onClick: () => { void handleSubmit(); },
disabled: !canSubmit,
}}
>
<div style={fieldWrap}>
<label style={labelStyle} htmlFor="ar-rationale">Rationale</label>
<textarea
id="ar-rationale"
autoFocus
placeholder="Why are you approving this call?"
value={rationale}
onChange={(e) => setRationale(e.target.value)}
style={textareaStyle}
disabled={submitting}
/>
</div>
{templates.length > 0 && (
<div style={{ ...fieldWrap, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{templates.map((t) => (
<button
key={t}
type="button"
onClick={() => setRationale(t)}
style={templateChip}
disabled={submitting}
>
{t}
</button>
))}
</div>
)}
{error && (
<div
role="alert"
style={{
marginTop: 12,
padding: '8px 10px',
fontFamily: 'var(--ff-mono)',
fontSize: 11,
color: 'var(--danger)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
}}
>
{error}
</div>
)}
</Modal>
);
}
Loading
Loading