feat(web): Phase B.3 Agent Inbox web UI + backend API client
This commit is contained in:
parent
e021e96c80
commit
23c584f4a8
245
web/src/app/inbox/page.tsx
Normal file
245
web/src/app/inbox/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
web/src/lib/agent-inbox-client.ts
Normal file
68
web/src/lib/agent-inbox-client.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user