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
117 changes: 117 additions & 0 deletions web/src/canvas/CanvasHead.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header style={wrap}>
<div style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
{isActive && (
<span
aria-hidden
style={{
display: 'inline-block', width: 6, height: 6,
borderRadius: '50%', background: 'var(--acc)',
animation: 'asr-pulse 2.4s ease-in-out infinite',
}}
/>
)}
<span>{eyebrowParts.join(' · ')}</span>
</div>
<h1
style={{
margin: '0 0 8px',
fontFamily: 'var(--ff-sans)',
fontSize: 30,
fontWeight: 500,
letterSpacing: '-0.018em',
color: 'var(--ink-1)',
lineHeight: 1.2,
}}
>
{truncTitle}
</h1>
<div
style={{
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
fontFamily: 'var(--ff-mono)', fontSize: 11, color: 'var(--ink-2)',
letterSpacing: '0.06em', textTransform: 'uppercase',
}}
>
<span>ENV {props.env}</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>SEV {props.sev}</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>REPORTER {props.reporter}</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>TURNS {props.turnCount}</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>TOOLS {props.toolCount}</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>AGENTS {props.agentsActive} of {props.agentsTotal}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" size="sm" onClick={props.onStop}>
<Icon name="stop" size={12} /> Stop
</Button>
{showRetry && (
<Button variant="secondary" size="sm" onClick={props.onRetry}>
<Icon name="retry" size={12} /> Retry
</Button>
)}
</div>
</header>
);
}
106 changes: 106 additions & 0 deletions web/src/canvas/HITLBand.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
low: 'var(--good)',
medium: 'var(--warn)',
high: 'var(--danger)',
};

function Kv({ k, v, color }: { k: string; v: string | number; color?: string }) {
return (
<div style={{ display: 'flex', gap: 6, fontFamily: 'var(--ff-mono)', fontSize: 11 }}>
<span style={{ width: 80, color: 'var(--ink-3)' }}>{k}</span>
<span style={{ flex: 1, color: color ?? 'var(--ink-1)' }}>{String(v)}</span>
</div>
);
}

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 (
<div style={grid}>
<div style={{ textAlign: 'right' }}>
<div
style={{
fontFamily: 'var(--ff-mono)', fontSize: 10,
color: 'var(--warn)', letterSpacing: '0.14em',
textTransform: 'uppercase', fontWeight: 600,
}}
>
APPROVAL
</div>
<div style={{ fontFamily: 'var(--ff-mono)', fontSize: 10, color: 'var(--ink-3)', marginTop: 4 }}>
waited {waitedSeconds} s
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<h2
style={{
margin: 0, fontFamily: 'var(--ff-sans)', fontSize: 18,
fontWeight: 500, color: 'var(--ink-1)', lineHeight: 1.35,
}}
>
{question}
</h2>
<div
style={{
fontFamily: 'var(--ff-mono)', fontSize: 12, color: 'var(--ink-2)',
padding: '6px 8px', background: 'var(--bg-elev)',
border: '1px solid var(--hair)',
}}
>
<span style={{ color: 'var(--acc)' }}>{toolCall.tool}</span>({argsStr})
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button variant="primary" size="sm" onClick={onApprove}>Approve</Button>
<Button variant="secondary" size="sm" onClick={onReject}>Reject</Button>
<span style={{ flex: 1 }} />
<Button variant="ghost" size="sm" onClick={onApproveWithRationale}>
Approve with Rationale
</Button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Kv k="Requested by" v={requestedBy} />
<Kv k="Turn" v={turn} />
<Kv k="Confidence" v={confidence === null ? '—' : confidence.toFixed(2)} />
<Kv
k="Risk"
v={toolCall.risk ?? 'unknown'}
color={riskColor[toolCall.risk ?? ''] ?? 'var(--ink-2)'}
/>
<Kv k="Policy" v={policy} />
</div>
</div>
);
}
56 changes: 56 additions & 0 deletions web/src/canvas/Sidenote.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ width: 64, color: 'var(--ink-3)' }}>{k}</span>
<span style={{ flex: 1, color: 'var(--ink-1)' }}>
{v === null ? '—' : String(v)}
</span>
</div>
);
}

export function Sidenote({
confidence, model, durationMs, turn, toolCalls, onSelectTool,
}: SidenoteProps) {
return (
<div style={wrap}>
<Kv k="conf" v={confidence === null ? null : confidence.toFixed(2)} />
<Kv k="model" v={model} />
<Kv k="dur" v={`${durationMs}ms`} />
<Kv k="turn" v={turn} />
{toolCalls.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginTop: 4 }}>
{toolCalls.map((tc, i) => (
<ToolCallCard
key={`${tc.ts}-${i}`}
tc={tc}
onClick={() => onSelectTool(tc)}
/>
))}
</div>
)}
</div>
);
}
65 changes: 65 additions & 0 deletions web/src/canvas/ToolCallCard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 (
<div data-tool-card onClick={onClick} style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 6 }}>
<span style={{ fontSize: 11, color: 'var(--ink-1)' }}>{tc.tool}</span>
<span
style={{
fontSize: 9,
letterSpacing: '0.14em',
color: statusColor[tc.status] ?? 'var(--ink-3)',
}}
>
{shortStatus(tc.status)}
</span>
</div>
{tc.risk && (
<span style={{ fontSize: 9, color: 'var(--ink-3)' }}>
risk: {tc.risk}
</span>
)}
</div>
);
}
24 changes: 24 additions & 0 deletions web/src/lib/hitl/questionFromToolCall.ts
Original file line number Diff line number Diff line change
@@ -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, string>,
): string {
const template = templates[tc.tool] ?? GENERIC;
return interpolate(template, {
agent: tc.agent,
tool: tc.tool,
risk: tc.risk ?? 'unknown',
...(tc.args as Record<string, unknown>),
});
}

function interpolate(template: string, vars: Record<string, unknown>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
const v = vars[key];
if (v === undefined || v === null) return match;
return String(v);
});
}
5 changes: 5 additions & 0 deletions web/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,8 @@ body {
animation: none !important;
}
}

@keyframes asr-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
Loading
Loading