diff --git a/.gitignore b/.gitignore index 026d65f..2880789 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ bin/ dist/ node_modules/ +.pnpm-store/ apps/web/dist/ apps/web/.vite/ *.db diff --git a/apps/web/package.json b/apps/web/package.json index ad874cc..8c26af3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,9 +24,12 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^24.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.2", "typescript": "^5.9.3", "vite": "^8.0.1" } diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 471d129..a56526f 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -6,24 +6,65 @@ import { createRootRoute, createRoute, createRouter, + useRouterState, } from '@tanstack/react-router'; -const queryClient = new QueryClient(); +import { cn } from './cadence-ui/lib/cn'; +import { + BlockedQueuePage, + RunDetailPage, + RunsPage, + ThreadTimelinePage, +} from './features/operator-console'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5_000, + refetchOnWindowFocus: true, + retry: 1, + }, + }, +}); const rootRoute = createRootRoute({ component: RootLayout, }); -const indexRoute = createRoute({ +const runsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: HomePage, + component: RunsPage, }); -const routeTree = rootRoute.addChildren([indexRoute]); +const blockedQueueRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/blocked', + component: BlockedQueuePage, +}); + +const runDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/runs/$runId', + component: RunDetailRoute, +}); + +const threadTimelineRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/threads/$threadId', + component: ThreadTimelineRoute, +}); + +const routeTree = rootRoute.addChildren([ + runsRoute, + blockedQueueRoute, + runDetailRoute, + threadTimelineRoute, +]); const router = createRouter({ routeTree, + defaultPreload: 'intent', }); declare module '@tanstack/react-router' { @@ -42,63 +83,119 @@ export function App() { function RootLayout() { return ( -
-
-
-

AI Workflow Skill

- - Orch Control Plane - +
+
+
+
+
+
+ +
+ + +
+
+
+
+

+ AI Workflow Skill +

+ + Orch Control Plane + +
+
+ + +
+
+
+ +
+ +
-

- Phase 1 keeps the UI thin while the backend contract settles around - `orchd`. -

-
-
- -
+
); } -function HomePage() { +function NavLink({ + exact = false, + label, + to, +}: { + exact?: boolean; + label: string; + to: string; +}) { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + const isActive = exact ? pathname === to : pathname === to || pathname.startsWith(`${to}/`); + return ( -
-
-

Current slice

-

Read-only operator shell for a future multi-user web product.

-

- The monorepo now has a dedicated React app, a Go HTTP service, and a - first API contract for runs, blocked work, and thread history. -

-
- -
-

Backend spine

- -
- -
-

Frontend posture

-

- React, Vite, TanStack Router, and TanStack Query are present now so - Phase 2 can focus on actual operator views instead of build plumbing. -

-
- -
-

Next UI targets

- -
-
+ + + {label} + ); } + +function RunDetailRoute() { + const { runId } = runDetailRoute.useParams(); + return ; +} + +function ThreadTimelineRoute() { + const { threadId } = threadTimelineRoute.useParams(); + return ; +} diff --git a/apps/web/src/features/operator-console.tsx b/apps/web/src/features/operator-console.tsx new file mode 100644 index 0000000..69982ef --- /dev/null +++ b/apps/web/src/features/operator-console.tsx @@ -0,0 +1,1190 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; +import { useDeferredValue, useState } from 'react'; + +import { Alert, AlertDescription, AlertTitle } from '../cadence-ui/components/alert'; +import { Badge } from '../cadence-ui/components/badge'; +import { Button } from '../cadence-ui/components/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '../cadence-ui/components/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '../cadence-ui/components/dialog'; +import { Input } from '../cadence-ui/components/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '../cadence-ui/components/tabs'; +import { Textarea } from '../cadence-ui/components/textarea'; +import { cn } from '../cadence-ui/lib/cn'; +import { + type BlockedQueueItem, + type BlockedTask, + type Message, + type Run, + type RunDetail, + type RunListItem, + type Task, + type ThreadDetail, + getRunDetail, + getThreadDetail, + listBlockedQueue, + listRuns, +} from '../lib/api'; +import { + formatDateTime, + formatJson, + hasStructuredData, + pluralize, + statusLabel, + uniqueCount, +} from '../lib/format'; + +const terminalStatuses = new Set(['cancelled', 'completed', 'done', 'failed']); + +export function RunsPage() { + const runsQuery = useQuery({ + queryKey: ['runs'], + queryFn: listRuns, + refetchInterval: 15_000, + }); + const [search, setSearch] = useState(''); + const deferredSearch = useDeferredValue(search.trim().toLowerCase()); + + const runs = runsQuery.data ?? []; + const filteredRuns = runs.filter((item) => matchesRun(item, deferredSearch)); + const totalRuns = runs.length; + const activeRuns = runs.filter((item) => !terminalStatuses.has(item.run.status)).length; + const blockedTasks = runs.reduce((sum, item) => sum + (item.task_counts.blocked ?? 0), 0); + const totalTasks = runs.reduce((sum, item) => sum + item.total_tasks, 0); + + return ( +
+ + setSearch(event.target.value)} + placeholder="Filter by run id, goal, or summary" + value={search} + /> + + + + + + 0 ? 'Need operator attention' : 'No current blockers'} + tone={blockedTasks > 0 ? 'accent' : 'subtle'} + /> + + + {runsQuery.isLoading ? ( + + ) : runsQuery.isError ? ( + void runsQuery.refetch()} + title="Unable to load orchestration runs" + /> + ) : filteredRuns.length === 0 ? ( + + ) : ( +
+ {filteredRuns.map((item) => ( + + ))} +
+ )} +
+ ); +} + +export function BlockedQueuePage() { + const blockedQuery = useQuery({ + queryKey: ['blocked-queue'], + queryFn: listBlockedQueue, + refetchInterval: 10_000, + }); + const [search, setSearch] = useState(''); + const deferredSearch = useDeferredValue(search.trim().toLowerCase()); + + const blockedItems = blockedQuery.data ?? []; + const filteredItems = blockedItems.filter((item) => matchesBlockedItem(item, deferredSearch)); + const impactedRuns = uniqueCount(blockedItems, (item) => item.run.run_id); + const waitingWorkers = uniqueCount(blockedItems, (item) => item.attempt.assigned_to); + + return ( +
+ + setSearch(event.target.value)} + placeholder="Filter by run, task, worker, or question" + value={search} + /> + + + + 0 ? 'Awaiting operator input' : 'Queue is clear'} + tone={blockedItems.length > 0 ? 'accent' : 'subtle'} + /> + + + + + {blockedQuery.isLoading ? ( + + ) : blockedQuery.isError ? ( + void blockedQuery.refetch()} + title="Unable to load the blocked queue" + /> + ) : filteredItems.length === 0 ? ( + + ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} +
+ ); +} + +export function RunDetailPage({ runId }: { runId: string }) { + const runQuery = useQuery({ + queryKey: ['run', runId], + queryFn: () => getRunDetail(runId), + refetchInterval: 10_000, + }); + const [search, setSearch] = useState(''); + const deferredSearch = useDeferredValue(search.trim().toLowerCase()); + + if (runQuery.isLoading) { + return ; + } + + if (runQuery.isError) { + return ( + void runQuery.refetch()} + title={`Unable to load run ${runId}`} + /> + ); + } + + const detail = runQuery.data; + if (!detail) { + return ( + void runQuery.refetch()} + title={`Missing run data for ${runId}`} + /> + ); + } + + const filteredTasks = detail.tasks.filter((task) => matchesTask(task, deferredSearch)); + const blockedByTaskID = new Map(detail.blocked_tasks.map((item) => [item.task.task_id, item] as const)); + const groupedTasks = groupTasks(filteredTasks); + const statusEntries = sortTaskCounts(detail.task_counts); + + return ( +
+ 0 ? 'accent' : 'default'}> + +
+
+
+ {detail.run.run_id} + + + {detail.total_tasks} total tasks + +
+ + {detail.run.goal} + + + {detail.run.summary} + +
+ +
+ + + + +
+
+
+ +
+ {statusEntries.map(([status, count]) => ( + + {statusLabel(status)} ยท {count} + + ))} +
+
+ + +
+
+
+ + {detail.blocked_tasks.length > 0 ? ( + } + variant="warning" + > + {detail.blocked_tasks.length} blocked {pluralize(detail.blocked_tasks.length, 'task')} + + This run currently has work waiting on a reply. Open the blocked tab for the question summaries and thread links. + + + ) : null} + + +
+ + Overview + Task board + + Blocked + + {detail.blocked_tasks.length} + + + + + setSearch(event.target.value)} + placeholder="Filter tasks by id, title, summary, or owner" + value={search} + /> +
+ + +
+ + + Run posture + + High-level task distribution across the current run state. + + + + + 0 ? 'Needs answer' : 'No blockers'} + tone={detail.blocked_tasks.length > 0 ? 'accent' : 'subtle'} + /> + + + + + + + Status mix + Current task counts grouped by orchestration status. + + + {statusEntries.map(([status, count]) => ( +
+
+ + {statusLabel(status)} +
+ {count} +
+ ))} +
+
+
+
+ + + {filteredTasks.length === 0 ? ( + + ) : ( +
+ {groupedTasks.map(([status, tasks]) => ( + + +
+
+ {statusLabel(status)} + {pluralize(tasks.length, 'task')} +
+ +
+
+ + {tasks.map((task) => ( + + ))} + +
+ ))} +
+ )} +
+ + + {detail.blocked_tasks.length === 0 ? ( + + ) : ( +
+ {detail.blocked_tasks.map((item) => ( + + ))} +
+ )} +
+
+
+ ); +} + +export function ThreadTimelinePage({ threadId }: { threadId: string }) { + const threadQuery = useQuery({ + queryKey: ['thread', threadId], + queryFn: () => getThreadDetail(threadId), + refetchInterval: 5_000, + }); + + if (threadQuery.isLoading) { + return ; + } + + if (threadQuery.isError) { + return ( + void threadQuery.refetch()} + title={`Unable to load thread ${threadId}`} + /> + ); + } + + const detail = threadQuery.data; + if (!detail) { + return ( + void threadQuery.refetch()} + title={`Missing thread data for ${threadId}`} + /> + ); + } + + const artifactCount = detail.messages.reduce((sum, message) => sum + message.artifacts.length, 0); + + return ( +
+ + +
+
+
+ {detail.thread.thread_id} + + +
+ + {detail.thread.subject} + + + Leader-to-worker timeline for task {detail.thread.task_id} in run {detail.thread.run_id}. + +
+ +
+ + + + +
+
+
+ +
+ + +
+
+ {pluralize(detail.messages.length, 'message')} + {pluralize(artifactCount, 'artifact')} +
+
+
+ + {detail.thread.status === 'blocked' ? ( + } variant="warning"> + Thread is blocked + + The active worker is waiting on an answer in this thread. Review the latest question before replying through the CLI or future operator actions. + + + ) : null} + + {detail.messages.length === 0 ? ( + + ) : ( +
+ {detail.messages.map((message, index) => ( + + ))} +
+ )} +
+ ); +} + +function PageHero({ + eyebrow, + title, + description, + children, +}: { + eyebrow: string; + title: string; + description: string; + children?: React.ReactNode; +}) { + return ( +
+ + +
+

+ {eyebrow} +

+ + {title} + + + {description} + +
+
+
+ + + + Operator filter + + Narrow the current surface without leaving the page. + + + {children} + +
+ ); +} + +function MetricsGrid({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function MetricCard({ + label, + value, + caption, + tone, +}: { + label: string; + value: string; + caption: string; + tone: 'accent' | 'default' | 'subtle'; +}) { + return ( + + +

+ {label} +

+
+

+ {value} +

+ {caption} +
+
+
+ ); +} + +function RunSummaryCard({ item }: { item: RunListItem }) { + const blockedCount = item.task_counts.blocked ?? 0; + + return ( + 0 ? 'accent' : 'default'}> + +
+
+ {item.run.run_id} + +
+ + Updated {formatDateTime(item.run.updated_at)} + +
+
+ + {item.run.goal} + + {item.run.summary} +
+
+ + + + + {sortTaskCounts(item.task_counts).map(([status, count]) => ( +
+ {statusLabel(status)} + {count} +
+ ))} +
+ + + + + +
+ ); +} + +function TaskCard({ + task, + blockedTask, +}: { + task: Task; + blockedTask?: BlockedTask; +}) { + return ( +
+
+
+ + {task.task_id} + + + +
+

+ {task.title} +

+

{task.summary}

+
+ +
+
+
Owner
+
{task.default_to || 'Unassigned'}
+
+
+
Latest attempt
+
{task.latest_attempt_no || 0}
+
+
+ + {blockedTask ? ( +
+

+ Waiting on reply +

+

+ {blockedTask.question.summary} +

+

+ {blockedTask.question.body} +

+
+ ) : null} + +
+ {blockedTask ? ( + + ) : null} + {hasStructuredData(task.acceptance_json) ? ( + + ) : null} +
+
+ ); +} + +function BlockedTaskCard({ item }: { item: BlockedQueueItem }) { + return ( + + +
+
+
+ {item.run.run_id} + {item.task.task_id} + +
+
+ {item.task.title} + {item.task.summary} +
+
+ +
+
+ + attempt {item.attempt.attempt_no} +
+ assigned to {item.attempt.assigned_to} + {formatDateTime(item.question.created_at)} +
+
+
+ + +
+

+ Latest question +

+

+ {item.question.summary} +

+

+ {item.question.body} +

+
+ +
+ + + +
+
+ + + + + {hasStructuredData(item.question.payload_json) ? ( + + ) : null} + +
+ ); +} + +function ThreadMessageCard({ + index, + message, + total, +}: { + index: number; + message: Message; + total: number; +}) { + return ( +
+
+
+ {index < total - 1 ? ( +
+ ) : null} +
+ + + +
+
+ + {message.kind} + + {message.from_agent} + to + {message.to_agent} +
+ + {formatDateTime(message.created_at)} + +
+
+ {message.summary} + + {message.message_id} + +
+
+ + +

+ {message.body} +

+ +
+ {hasStructuredData(message.payload_json) ? ( + + ) : null} + {message.artifacts.length > 0 ? ( + + {pluralize(message.artifacts.length, 'artifact')} + + ) : null} +
+ + {message.artifacts.length > 0 ? ( +
+ {message.artifacts.map((artifact) => ( +
+
+

{artifact.path}

+

+ {artifact.kind} +

+
+ {hasStructuredData(artifact.metadata_json) ? ( + + ) : null} +
+ ))} +
+ ) : null} +
+
+
+ ); +} + +function QueryErrorState({ + title, + description, + onRetry, +}: { + title: string; + description: string; + onRetry: () => void; +}) { + return ( + } variant="destructive"> + {title} + + {description} +
+ +
+
+
+ ); +} + +function EmptyState({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + + +
+ +
+
+

+ {title} +

+

+ {description} +

+
+
+
+ ); +} + +function LoadingState({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }, (_, index) => ( + + +
+
+
+
+ + + ))} +
+ ); +} + +function CompactStat({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+

+ {label} +

+

{value}

+
+ ); +} + +function JsonDialog({ + label, + title, + description, + value, +}: { + label: string; + title: string; + description: string; + value: unknown; +}) { + const formatted = formatJson(value); + const rows = Math.min(Math.max(formatted.split('\n').length, 8), 18); + + return ( + + + + + + + {title} + {description} + +