From 23c584f4a81102e13e59721aa078b9abcfc0fb3c Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 18 Apr 2026 18:05:50 -0700 Subject: [PATCH] feat(web): Phase B.3 Agent Inbox web UI + backend API client --- web/src/app/inbox/page.tsx | 245 ++++++++++++++++++++++++++++++ web/src/lib/agent-inbox-client.ts | 68 +++++++++ 2 files changed, 313 insertions(+) create mode 100644 web/src/app/inbox/page.tsx create mode 100644 web/src/lib/agent-inbox-client.ts diff --git a/web/src/app/inbox/page.tsx b/web/src/app/inbox/page.tsx new file mode 100644 index 0000000..90ed5eb --- /dev/null +++ b/web/src/app/inbox/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('proposed'); + const [error, setError] = useState(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 ( +
+
+ +

Agent Inbox is not enabled. Enable the agent_inbox.enabled flag.

+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + + +
+

+ Agent Inbox +

+

+ Review AI-proposed timer actions +

+
+ + {proposedCount > 0 && ( + + )} +
+ + {/* Filter tabs */} +
+ {(['proposed', 'approved', 'rejected', 'all'] as FilterState[]).map(f => ( + + ))} +
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Actions list */} + {loading ? ( +
+ +

Loading...

+
+ ) : actions.length === 0 ? ( +
+ +

+ {filter === 'proposed' ? 'No pending actions to review.' : 'No actions found.'} +

+
+ ) : ( +
+ {actions.map(action => ( +
+
+
+
+ + {action.toolName} + + + {action.state} + +
+

+ {action.description} +

+ {action.inputSummary && ( +

+ Input: {action.inputSummary} +

+ )} +
+
+ +
+ + {action.actorId} · {new Date(action.createdAt).toLocaleTimeString()} + + + {action.state === 'proposed' && ( +
+ + +
+ )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/src/lib/agent-inbox-client.ts b/web/src/lib/agent-inbox-client.ts new file mode 100644 index 0000000..d64b1d9 --- /dev/null +++ b/web/src/lib/agent-inbox-client.ts @@ -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(path: string, opts?: RequestInit): Promise { + 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; +} + +// ── 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 { + const params = new URLSearchParams(); + if (state) params.set('state', state); + return apiFetch(`/api/agent-actions?${params}`); +} + +export async function approveAction(id: string): Promise { + return apiFetch(`/api/agent-actions/${id}/approve`, { method: 'POST' }); +} + +export async function rejectAction(id: string): Promise { + return apiFetch(`/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 }), + }); +}