feat(web): Phase B.3 Agent Inbox web UI + backend API client

This commit is contained in:
saravanakumardb1 2026-04-18 18:05:50 -07:00
parent e021e96c80
commit 23c584f4a8
2 changed files with 313 additions and 0 deletions

245
web/src/app/inbox/page.tsx Normal file
View File

@ -0,0 +1,245 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { ArrowLeft, Check, X, RefreshCw, CheckCheck, Bot } from 'lucide-react';
import {
listAgentActions,
approveAction,
rejectAction,
batchApprove,
type AgentAction,
} from '@/lib/agent-inbox-client';
import { isEnabled } from '@/lib/feature-flags';
import { trackEvent } from '@/lib/telemetry';
type FilterState = 'proposed' | 'approved' | 'rejected' | 'all';
export default function InboxPage() {
const [actions, setActions] = useState<AgentAction[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<FilterState>('proposed');
const [error, setError] = useState<string | null>(null);
const enabled = isEnabled('agent_inbox.enabled');
const loadActions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const stateFilter = filter === 'all' ? undefined : filter;
const result = await listAgentActions(stateFilter);
setActions(result.items);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load actions');
}
setLoading(false);
}, [filter]);
useEffect(() => {
if (enabled) loadActions();
}, [enabled, loadActions]);
const handleApprove = async (id: string) => {
try {
await approveAction(id);
trackEvent('info', 'agent_inbox', 'approve_action');
loadActions();
} catch {
setError('Failed to approve action');
}
};
const handleReject = async (id: string) => {
try {
await rejectAction(id);
trackEvent('info', 'agent_inbox', 'reject_action');
loadActions();
} catch {
setError('Failed to reject action');
}
};
const handleBatchApprove = async () => {
const proposedActions = actions.filter(a => a.state === 'proposed');
const actorIds = [...new Set(proposedActions.map(a => a.actorId))];
try {
for (const actorId of actorIds) {
await batchApprove(actorId);
}
trackEvent('info', 'agent_inbox', 'batch_approve');
loadActions();
} catch {
setError('Failed to batch approve');
}
};
const proposedCount = actions.filter(a => a.state === 'proposed').length;
if (!enabled) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="text-center">
<Bot size={48} style={{ color: 'var(--cm-text-tertiary)' }} className="mx-auto mb-4" />
<p className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>Agent Inbox is not enabled. Enable the <code>agent_inbox.enabled</code> flag.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="max-w-2xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Link
href="/"
className="p-2 rounded-lg"
style={{ color: 'var(--cm-text-secondary)' }}
aria-label="Back to dashboard"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-xl font-bold" style={{ color: 'var(--cm-text-primary)' }}>
Agent Inbox
</h1>
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
Review AI-proposed timer actions
</p>
</div>
<button
onClick={loadActions}
aria-label="Refresh inbox"
className="p-2 rounded-lg cursor-pointer"
style={{ color: 'var(--cm-text-secondary)' }}
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
{proposedCount > 0 && (
<button
onClick={handleBatchApprove}
aria-label="Approve all proposed actions"
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-gentle-15)', color: 'var(--cm-gentle)' }}
>
<CheckCheck size={14} /> Approve All ({proposedCount})
</button>
)}
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-4">
{(['proposed', 'approved', 'rejected', 'all'] as FilterState[]).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
aria-label={`Filter by ${f}`}
className="px-3 py-1.5 rounded-lg text-xs font-medium capitalize cursor-pointer"
style={{
backgroundColor: filter === f ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
color: filter === f ? 'var(--cm-white)' : 'var(--cm-text-secondary)',
}}
>
{f}
</button>
))}
</div>
{/* Error */}
{error && (
<div
className="rounded-lg p-3 mb-4 text-sm"
style={{ backgroundColor: 'var(--cm-critical-10)', color: 'var(--cm-danger)' }}
role="alert"
>
{error}
</div>
)}
{/* Actions list */}
{loading ? (
<div className="text-center py-12">
<RefreshCw size={24} className="animate-spin mx-auto mb-2" style={{ color: 'var(--cm-text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>Loading...</p>
</div>
) : actions.length === 0 ? (
<div className="text-center py-12">
<Bot size={32} style={{ color: 'var(--cm-text-tertiary)' }} className="mx-auto mb-3" />
<p className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>
{filter === 'proposed' ? 'No pending actions to review.' : 'No actions found.'}
</p>
</div>
) : (
<div className="space-y-3">
{actions.map(action => (
<div
key={action.id}
className="rounded-xl border p-4"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
{action.toolName}
</span>
<span
className="px-2 py-0.5 rounded text-[10px] font-medium uppercase"
style={{
backgroundColor: action.state === 'proposed' ? 'var(--cm-important-15)'
: action.state === 'approved' ? 'var(--cm-gentle-15)'
: 'var(--cm-critical-10)',
color: action.state === 'proposed' ? 'var(--cm-important)'
: action.state === 'approved' ? 'var(--cm-gentle)'
: 'var(--cm-danger)',
}}
>
{action.state}
</span>
</div>
<p className="text-xs" style={{ color: 'var(--cm-text-secondary)' }}>
{action.description}
</p>
{action.inputSummary && (
<p className="text-[11px] mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
Input: {action.inputSummary}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between mt-3">
<span className="text-[10px]" style={{ color: 'var(--cm-text-tertiary)' }}>
{action.actorId} · {new Date(action.createdAt).toLocaleTimeString()}
</span>
{action.state === 'proposed' && (
<div className="flex gap-2">
<button
onClick={() => handleApprove(action.id)}
aria-label={`Approve action: ${action.toolName}`}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-gentle-15)', color: 'var(--cm-gentle)' }}
>
<Check size={14} /> Approve
</button>
<button
onClick={() => handleReject(action.id)}
aria-label={`Reject action: ${action.toolName}`}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-critical-10)', color: 'var(--cm-danger)' }}
>
<X size={14} /> Reject
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
/**
* Agent Inbox API client talks to ChronoMind backend (port 4011).
*/
import { getBackendBaseURL } from './product-config';
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('chronomind_access_token');
}
async function apiFetch<T>(path: string, opts?: RequestInit): Promise<T> {
const token = getToken();
const res = await fetch(`${getBackendBaseURL()}${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`${res.status}: ${body}`);
}
return res.json() as Promise<T>;
}
// ── Types ──
export interface AgentAction {
id: string;
actorId: string;
toolName: string;
description: string;
state: 'proposed' | 'approved' | 'rejected' | 'executed';
inputSummary?: string;
outputSummary?: string;
createdAt: string;
}
export interface AgentActionList {
items: AgentAction[];
total: number;
}
// ── API calls ──
export async function listAgentActions(state?: string): Promise<AgentActionList> {
const params = new URLSearchParams();
if (state) params.set('state', state);
return apiFetch<AgentActionList>(`/api/agent-actions?${params}`);
}
export async function approveAction(id: string): Promise<AgentAction> {
return apiFetch<AgentAction>(`/api/agent-actions/${id}/approve`, { method: 'POST' });
}
export async function rejectAction(id: string): Promise<AgentAction> {
return apiFetch<AgentAction>(`/api/agent-actions/${id}/reject`, { method: 'POST' });
}
export async function batchApprove(actorId: string): Promise<{ approved: string[] }> {
return apiFetch<{ approved: string[] }>('/api/agent-actions/batch-approve', {
method: 'POST',
body: JSON.stringify({ actorId }),
});
}