-
AI Workflow Skill
-
- Orch Control Plane
-
+
+
+
+
+
+
+
+
+ AI Workflow Skill
+
+
+
+ Orch Control Plane
+
+
+ Read-only operator surfaces for runs, blocked work, and thread history.
+
+
+
+
+
+
+
+
+
+
+
+ Current slice
+
+
+ Runs list with health counts
+ Run detail with task board
+ Global blocked queue
+ Thread timeline and artifacts
+
+
+
+
+
+
+
+
+
+
+ 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
-
- `cmd/orchd` serves `chi` routes against the existing SQLite state.
- `internal/query` shapes run, blocked-task, and thread reads.
- `api/openapi.yaml` is the contract anchor for future typed clients.
-
-
-
-
- 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
-
- Runs dashboard with task-count health signals.
- Run detail board grouped by orchestration state.
- Blocked queue and thread timeline wired to live refresh.
-
-
-
+
+
+ {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}
+
+ ))}
+
+
+
+ Back to runs
+
+
+ Global blocked queue
+
+
+
+
+
+ {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}.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to run
+
+
+
+ Blocked queue
+
+
+
+ {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 ;
+}
+
+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}
+
+ ))}
+
+
+
+
+
+ Open run
+
+
+
+ Blocked queue
+
+
+
+ );
+}
+
+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 ? (
+
+
+ Open timeline
+
+
+ ) : 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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open run
+
+
+
+
+ Thread timeline
+
+
+ {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}
+
+
+ Retry request
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {label}
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+ );
+}
+
+function StatusBadge({ status }: { status: string }) {
+ return (
+
+ {statusLabel(status)}
+
+ );
+}
+
+function PriorityBadge({ priority }: { priority: string }) {
+ const normalized = priority.toLowerCase();
+ const tone =
+ normalized === 'high'
+ ? 'warning'
+ : normalized === 'urgent'
+ ? 'destructive'
+ : normalized === 'low'
+ ? 'neutral'
+ : 'primary';
+
+ return (
+
+ {priority}
+
+ );
+}
+
+function badgeToneForStatus(status: string): 'destructive' | 'neutral' | 'primary' | 'success' | 'warning' {
+ switch (status) {
+ case 'blocked':
+ return 'warning';
+ case 'done':
+ case 'completed':
+ return 'success';
+ case 'failed':
+ case 'cancelled':
+ return 'destructive';
+ case 'active':
+ case 'in_progress':
+ return 'primary';
+ default:
+ return 'neutral';
+ }
+}
+
+function sortTaskCounts(counts: Record) {
+ const order = ['blocked', 'in_progress', 'active', 'ready', 'pending', 'done', 'completed', 'failed', 'cancelled'];
+ const orderIndex = new Map(order.map((status, index) => [status, index] as const));
+
+ return Object.entries(counts).sort((left, right) => {
+ const leftOrder = orderIndex.get(left[0]) ?? Number.MAX_SAFE_INTEGER;
+ const rightOrder = orderIndex.get(right[0]) ?? Number.MAX_SAFE_INTEGER;
+
+ if (leftOrder !== rightOrder) {
+ return leftOrder - rightOrder;
+ }
+
+ return left[0].localeCompare(right[0]);
+ });
+}
+
+function groupTasks(tasks: Task[]) {
+ const grouped = new Map();
+
+ for (const task of tasks) {
+ const bucket = grouped.get(task.status);
+ if (bucket) {
+ bucket.push(task);
+ continue;
+ }
+ grouped.set(task.status, [task]);
+ }
+
+ return sortTaskCounts(
+ Object.fromEntries(Array.from(grouped.entries(), ([status, items]) => [status, items.length])),
+ ).map(([status]) => [status, grouped.get(status) ?? []] as const);
+}
+
+function matchesRun(item: RunListItem, filter: string) {
+ if (!filter) {
+ return true;
+ }
+
+ return [item.run.run_id, item.run.goal, item.run.summary].some((value) =>
+ value.toLowerCase().includes(filter),
+ );
+}
+
+function matchesTask(task: Task, filter: string) {
+ if (!filter) {
+ return true;
+ }
+
+ return [task.task_id, task.title, task.summary, task.default_to, task.priority].some(
+ (value) => value?.toLowerCase().includes(filter) ?? false,
+ );
+}
+
+function matchesBlockedItem(item: BlockedQueueItem, filter: string) {
+ if (!filter) {
+ return true;
+ }
+
+ return [
+ item.run.run_id,
+ item.task.task_id,
+ item.task.title,
+ item.task.summary,
+ item.attempt.assigned_to,
+ item.question.summary,
+ item.question.body,
+ item.question.from_agent,
+ item.question.to_agent,
+ ].some((value) => value.toLowerCase().includes(filter));
+}
+
+function SignalIcon() {
+ return (
+
+
+
+ );
+}
+
+function InboxIcon() {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
new file mode 100644
index 0000000..a5ffb85
--- /dev/null
+++ b/apps/web/src/lib/api.ts
@@ -0,0 +1,198 @@
+export type Run = {
+ run_id: string;
+ goal: string;
+ summary: string;
+ status: string;
+ created_at: string;
+ updated_at: string;
+};
+
+export type RunListItem = {
+ run: Run;
+ task_counts: Record;
+ total_tasks: number;
+};
+
+export type Task = {
+ run_id: string;
+ task_id: string;
+ title: string;
+ summary: string;
+ status: string;
+ default_to?: string;
+ priority: string;
+ acceptance_json: unknown;
+ latest_attempt_no?: number;
+ created_at: string;
+ updated_at: string;
+};
+
+export type TaskAttempt = {
+ run_id: string;
+ task_id: string;
+ attempt_no: number;
+ assigned_to: string;
+ thread_id: string;
+ base_ref?: string;
+ base_commit?: string;
+ branch_name?: string;
+ worktree_path?: string;
+ workspace_status?: string;
+ result_commit?: string;
+ status: string;
+ created_at: string;
+ updated_at: string;
+};
+
+export type Artifact = {
+ artifact_id: string;
+ message_id: string;
+ path: string;
+ kind: string;
+ metadata_json: unknown;
+ created_at: string;
+};
+
+export type Message = {
+ message_id: string;
+ thread_id: string;
+ from_agent: string;
+ to_agent: string;
+ kind: string;
+ summary: string;
+ body: string;
+ payload_json: unknown;
+ created_at: string;
+ artifacts: Artifact[];
+};
+
+export type Thread = {
+ thread_id: string;
+ run_id: string;
+ task_id: string;
+ subject: string;
+ created_by: string;
+ assigned_to: string;
+ status: string;
+ priority: string;
+ latest_message_id?: string;
+ created_at: string;
+ updated_at: string;
+};
+
+export type ThreadDetail = {
+ thread: Thread;
+ messages: Message[];
+};
+
+export type BlockedTask = {
+ task: Task;
+ attempt: TaskAttempt;
+ question: Message;
+};
+
+export type BlockedQueueItem = BlockedTask & {
+ run: Run;
+};
+
+export type RunDetail = {
+ run: Run;
+ task_counts: Record;
+ total_tasks: number;
+ tasks: Task[];
+ blocked_tasks: BlockedTask[];
+};
+
+type ErrorEnvelope = {
+ error?: {
+ code?: string;
+ message?: string;
+ };
+};
+
+class ApiError extends Error {
+ code?: string;
+ status: number;
+
+ constructor(message: string, status: number, code?: string) {
+ super(message);
+ this.name = 'ApiError';
+ this.code = code;
+ this.status = status;
+ }
+}
+
+const apiBaseUrl = (import.meta.env.VITE_ORCH_API_BASE_URL ?? '').replace(/\/$/, '');
+
+async function request(path: string): Promise {
+ const response = await fetch(`${apiBaseUrl}${path}`, {
+ cache: 'no-store',
+ headers: {
+ Accept: 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ let message = `Request failed with status ${response.status}`;
+ let code: string | undefined;
+
+ try {
+ const payload = (await response.json()) as ErrorEnvelope;
+ message = payload.error?.message ?? message;
+ code = payload.error?.code;
+ } catch {
+ // Ignore malformed error payloads and fall back to the HTTP status.
+ }
+
+ throw new ApiError(message, response.status, code);
+ }
+
+ return (await response.json()) as T;
+}
+
+export async function listRuns() {
+ const payload = await request<{ runs: RunListItem[] }>('/api/runs');
+ return payload.runs;
+}
+
+export async function getRunDetail(runId: string) {
+ const payload = await request<{ run: RunDetail }>(`/api/runs/${encodeURIComponent(runId)}`);
+ return payload.run;
+}
+
+export async function listRunBlocked(runId: string) {
+ const payload = await request<{ blocked: BlockedTask[] }>(
+ `/api/runs/${encodeURIComponent(runId)}/blocked`,
+ );
+ return payload.blocked;
+}
+
+export async function getThreadDetail(threadId: string) {
+ const payload = await request<{ thread: ThreadDetail }>(
+ `/api/threads/${encodeURIComponent(threadId)}`,
+ );
+ return {
+ ...payload.thread,
+ messages: payload.thread.messages.map((message) => ({
+ ...message,
+ artifacts: message.artifacts ?? [],
+ })),
+ };
+}
+
+export async function listBlockedQueue() {
+ const runs = await listRuns();
+ const blockedByRun = await Promise.all(
+ runs.map(async (item) => {
+ const blocked = await listRunBlocked(item.run.run_id);
+ return blocked.map((entry) => ({
+ ...entry,
+ run: item.run,
+ }));
+ }),
+ );
+
+ return blockedByRun
+ .flat()
+ .sort((left, right) => Date.parse(right.question.created_at) - Date.parse(left.question.created_at));
+}
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
new file mode 100644
index 0000000..a4b8b7c
--- /dev/null
+++ b/apps/web/src/lib/format.ts
@@ -0,0 +1,70 @@
+export function formatDateTime(value: string) {
+ const parsed = new Date(value);
+
+ if (Number.isNaN(parsed.getTime())) {
+ return value;
+ }
+
+ return new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(parsed);
+}
+
+export function statusLabel(value: string) {
+ return value.replaceAll('_', ' ');
+}
+
+export function pluralize(count: number, singular: string) {
+ return `${count} ${count === 1 ? singular : `${singular}s`}`;
+}
+
+export function hasStructuredData(value: unknown): boolean {
+ if (value == null) {
+ return false;
+ }
+
+ if (typeof value === 'string') {
+ return value.trim().length > 0 && value.trim() !== '{}' && value.trim() !== '[]';
+ }
+
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+
+ if (typeof value === 'object') {
+ return Object.keys(value as Record).length > 0;
+ }
+
+ return true;
+}
+
+export function formatJson(value: unknown) {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+
+ if (!trimmed) {
+ return '';
+ }
+
+ try {
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
+ } catch {
+ return value;
+ }
+ }
+
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+export function uniqueCount(items: T[], select: (item: T) => string | undefined) {
+ const values = items
+ .map(select)
+ .filter((value): value is string => Boolean(value));
+
+ return new Set(values).size;
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 8c13677..3c9ab92 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -1,33 +1,25 @@
+@import "tailwindcss";
+@source "./**/*.{ts,tsx}";
+
:root {
color-scheme: light;
- font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
- line-height: 1.5;
- font-weight: 400;
- background:
- radial-gradient(circle at top left, rgba(255, 196, 98, 0.32), transparent 28%),
- radial-gradient(circle at bottom right, rgba(8, 145, 178, 0.16), transparent 26%),
- linear-gradient(135deg, #f4efe3 0%, #f9f7f1 55%, #eef3f4 100%);
- color: #172026;
- --border: rgba(23, 32, 38, 0.12);
- --panel: rgba(255, 255, 255, 0.82);
- --panel-strong: rgba(255, 255, 255, 0.92);
- --ink-soft: rgba(23, 32, 38, 0.7);
- --accent: #c96f20;
- --accent-deep: #0f766e;
}
-* {
- box-sizing: border-box;
+html {
+ min-height: 100%;
+ scroll-behavior: smooth;
}
body {
- margin: 0;
min-width: 320px;
- min-height: 100vh;
+ background:
+ radial-gradient(circle at top, oklch(0.92 0.06 175 / 0.86), transparent 35%),
+ radial-gradient(circle at right center, oklch(0.84 0.13 88 / 0.22), transparent 30%),
+ linear-gradient(135deg, oklch(0.975 0.012 172) 0%, oklch(0.962 0.015 164) 45%, oklch(0.988 0.006 92) 100%);
+ background-attachment: fixed;
}
a {
- color: inherit;
text-decoration: none;
}
@@ -35,120 +27,10 @@ a {
min-height: 100vh;
}
-.shell {
- min-height: 100vh;
- padding: 32px 20px 48px;
+.type-display {
+ font-family: var(--font-display);
}
-.masthead {
- display: grid;
- gap: 18px;
- align-items: end;
- max-width: 1200px;
- margin: 0 auto 28px;
-}
-
-.brand {
- display: inline-block;
- font-family: "Iowan Old Style", "Palatino Linotype", serif;
- font-size: clamp(2rem, 4vw, 3.2rem);
- letter-spacing: -0.04em;
-}
-
-.eyebrow {
- margin: 0 0 10px;
- text-transform: uppercase;
- letter-spacing: 0.16em;
- font-size: 0.78rem;
- color: var(--accent-deep);
-}
-
-.masthead-copy {
- max-width: 34rem;
- margin: 0;
- color: var(--ink-soft);
-}
-
-.content {
- max-width: 1200px;
- margin: 0 auto;
-}
-
-.hero-grid {
- display: grid;
- gap: 18px;
- grid-template-columns: repeat(12, minmax(0, 1fr));
-}
-
-.hero-card {
- grid-column: span 12;
- padding: 24px;
- border: 1px solid var(--border);
- border-radius: 24px;
- background: var(--panel);
- backdrop-filter: blur(12px);
- box-shadow: 0 20px 50px rgba(23, 32, 38, 0.08);
-}
-
-.hero-card-primary {
- background:
- linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(255, 248, 237, 0.94));
-}
-
-.hero-card-secondary {
- background:
- linear-gradient(145deg, rgba(240, 249, 255, 0.95), rgba(240, 253, 250, 0.9));
-}
-
-.hero-card h1,
-.hero-card h2 {
- margin: 0 0 12px;
- font-family: "Iowan Old Style", "Palatino Linotype", serif;
- letter-spacing: -0.03em;
-}
-
-.hero-card h1 {
- font-size: clamp(2rem, 4.4vw, 4.4rem);
- line-height: 0.94;
- max-width: 12ch;
-}
-
-.hero-card h2 {
- font-size: 1.5rem;
-}
-
-.lede,
-.hero-card p {
- margin: 0;
- max-width: 48rem;
- color: var(--ink-soft);
-}
-
-.detail-list {
- margin: 0;
- padding-left: 18px;
- color: var(--ink-soft);
-}
-
-.detail-list li + li {
- margin-top: 10px;
-}
-
-@media (min-width: 840px) {
- .masthead {
- grid-template-columns: 1.2fr 0.8fr;
- }
-
- .hero-card-primary {
- grid-column: span 7;
- min-height: 420px;
- }
-
- .hero-card-secondary {
- grid-column: span 5;
- }
-
- .hero-card:not(.hero-card-primary):not(.hero-card-secondary) {
- grid-column: span 6;
- }
+.type-mono {
+ font-family: var(--font-mono);
}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
index 11f02fe..7a8d585 100644
--- a/apps/web/src/vite-env.d.ts
+++ b/apps/web/src/vite-env.d.ts
@@ -1 +1,9 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_ORCH_API_BASE_URL?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 9cfb0ce..13f911e 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,14 +1,17 @@
+import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
+const proxyTarget = process.env.ORCHD_PROXY_TARGET ?? 'http://127.0.0.1:8080';
+
export default defineConfig({
- plugins: [react()],
+ plugins: [tailwindcss(), react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
- '/api': 'http://localhost:8080',
- '/health': 'http://localhost:8080',
+ '/api': proxyTarget,
+ '/health': proxyTarget,
},
},
});
diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md
index f00d9af..0399781 100644
--- a/docs/implementation-roadmap.md
+++ b/docs/implementation-roadmap.md
@@ -35,6 +35,7 @@ As of now:
- `orchd` now serves a minimal read-only web API with `chi`, including `/health`, runs list/detail, run task list, blocked-task list, and thread detail endpoints backed by the existing SQLite state
- HTTP tests now cover the initial read-only `orchd` slice, and the new frontend workspace builds successfully with `pnpm run web:build`
- Phase 2 frontend work has now started by bootstrapping `apps/web` with copied-in `cadence-ui` tokens and foundational components for button, input, textarea, dialog, form, tabs, card, badge, and alert, with the shared token stylesheet loaded from the frontend entrypoint
+- the first real Phase 2 read-only operator UI is now implemented in `apps/web`, including routed runs list, run detail, blocked queue, and thread timeline views backed by the existing `orchd` HTTP API, plus Tailwind v4 consumer wiring so the source-owned Cadence UI components render correctly in the app
- a repo-local `scripts/package_skill_clis.sh` packaging flow now builds bundled skill CLI assets for `inbox`, `orch`, and `council-review`
- `orch` now implements `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `wait`, `blocked`, `answer`, `retry`, `reassign`, `cancel`, `cleanup`, and `status`
- `orch` can create runs, gate tasks through dependencies, dispatch work through `inbox`, reconcile worker thread state back into task state, answer blocked tasks, retry or reassign work, cancel tasks or runs, clean attempt worktrees, and create per-attempt Git worktrees during strict dispatch
@@ -93,9 +94,9 @@ Current implementation status:
- `Milestone 6: Waiting Primitives` is complete
- `Milestone 7: Council Review` is complete
- `Milestone 8: Web Product Phase 1 Skeleton` is complete
-- `Milestone 9: Web Product Phase 2 UI Foundation` is in progress
+- `Milestone 9: Web Product Phase 2 Read-Only Operator UI` is complete for the initial operator surface
-The council review v1 surface is complete, the first web-product skeleton now exists as a separate monorepo workspace plus read-only HTTP backend slice, and Phase 2 frontend work has started on top of the internal Cadence UI component library.
+The council review v1 surface is complete, the first web-product skeleton now exists as a separate monorepo workspace plus read-only HTTP backend slice, and the first real operator-facing Phase 2 read-only web views now exist on top of the internal Cadence UI component library.
### Milestone 1: Go Skeleton
@@ -416,38 +417,45 @@ Remaining:
- Phase 2 should turn the frontend shell into actual run, task-board, blocked-queue, and thread-detail pages using the new HTTP contract
-### Milestone 9: Web Product Phase 2 UI Foundation
+### Milestone 9: Web Product Phase 2 Read-Only Operator UI
Goal:
-- bootstrap the frontend UI layer on top of the Phase 1 shell and read-only backend contract
+- implement the first real operator-facing read-only web UI on top of the Phase 1 shell and current `orchd` API contract
Add:
- copied-in `cadence-ui` primitives and token CSS under `apps/web/src/cadence-ui`
-- app-wide token style wiring in the frontend entrypoint
-- any additional component installs needed as real screens land
+- Tailwind v4 consumer setup so the copied-in Cadence UI source renders correctly in the app
+- routed screens for runs list, run detail, blocked queue, and thread timeline
+- typed frontend read helpers for the current `orchd` endpoints
Definition of done:
- `apps/web` imports the shared Cadence token stylesheet from the frontend entrypoint
-- the initial shared component set builds successfully inside the workspace
+- the Cadence UI source-owned components render correctly inside the consumer app
+- the first routed read-only operator screens ship against the existing `orchd` contract
- future web screens can compose from `cadence-ui` primitives instead of raw one-off HTML controls
Status:
-- in progress
+- completed for the first read-only operator slice
Completed so far:
- `apps/web/src/cadence-ui/` now contains copied-in Cadence UI tokens plus foundational components for button, input, textarea, dialog, form, tabs, card, badge, and alert
- `apps/web/package.json` now includes the required Radix, `react-hook-form`, `motion`, and utility dependencies for the copied-in components
- `apps/web/src/main.tsx` now imports `./cadence-ui/tokens/styles.css`
-- `pnpm install` refreshed the workspace lockfile, and `pnpm run web:build` succeeds with the copied-in component slice
+- `apps/web` now includes Tailwind v4 consumer wiring in Vite and the global stylesheet so the copied-in Cadence UI utility classes render correctly
+- `apps/web` now includes a typed frontend read layer for runs, run detail, blocked queue aggregation, and thread detail
+- `apps/web` now ships routed runs list, run detail, blocked queue, and thread timeline pages using Cadence UI source-owned components for cards, tabs, alerts, inputs, badges, buttons, dialogs, and textareas
+- the run detail view now includes grouped task boards and blocked-task summaries, while the thread timeline view now shows message payload and artifact metadata inspectors
+- `pnpm run web:build` succeeds for the new operator UI, and local Vite-to-`orchd` proxy smoke checks confirm the frontend can read the seeded runs, blocked, and thread endpoints through the dev server
Remaining:
-- build the actual runs list, run detail, blocked queue, and thread timeline screens on top of the Cadence UI primitives
+- add operator write actions such as answer, retry, reassign, and cancel on top of the new read-only screens
+- add council result/report views and realtime event handling on top of the current routed UI
- install additional `cadence-ui` components on demand as the product surface expands
## Immediate Next Task
@@ -456,11 +464,13 @@ If a new agent is taking over now, the next concrete step should be:
1. treat `Milestone 8: Web Product Phase 1 Skeleton` as complete unless a new user request reopens the backend skeleton itself
2. keep the authored inbox test-plan set in `docs/tests/inbox/` synchronized if future `orch` or web work changes shared CLI-visible behavior
-3. continue `Milestone 9: Web Product Phase 2 UI Foundation` by implementing the first runs list, run detail, blocked queue, and thread timeline screens on top of the existing `apps/web` and `orchd` contract
-4. install additional `cadence-ui` components on demand when those screens need them, instead of reintroducing bespoke primitives into `apps/web`
-5. keep `api/openapi.yaml`, `api/events.md`, and `docs/web-product-monorepo.md` synchronized as the web surface expands
+3. treat `Milestone 9: Web Product Phase 2 Read-Only Operator UI` as complete for the initial operator surface and build the next web slice on top of the shipped read pages rather than replacing them
+4. start the next web phase by wiring operator write actions such as answer, retry, reassign, and cancel into the existing runs, blocked, and thread views
+5. add council result/report screens and realtime event handling after the operator write path is clear
+6. install additional `cadence-ui` components on demand when those screens need them, instead of reintroducing bespoke primitives into `apps/web`
+7. keep `api/openapi.yaml`, `api/events.md`, and `docs/web-product-monorepo.md` synchronized as the web surface expands
-The inbox implementation and its human-readable test-plan set are already in place, `orch` supports the main scheduler loop plus the complete council start/wait/tally/report workflow, and the web product is now past the bare frontend shell stage, so the next step should be actual Phase 2 product screens built on top of the Cadence UI foundation rather than reopening earlier milestones.
+The inbox implementation and its human-readable test-plan set are already in place, `orch` supports the main scheduler loop plus the complete council start/wait/tally/report workflow, and the web product now has its first real operator-facing read surfaces, so the next step should be write-capable operator workflows and council/realtime expansion rather than reopening the frontend shell or basic read pages.
## Recommended Driver Choices
diff --git a/docs/roadmaps/archive/web-phase2-read-only-ui.md b/docs/roadmaps/archive/web-phase2-read-only-ui.md
new file mode 100644
index 0000000..190d8ad
--- /dev/null
+++ b/docs/roadmaps/archive/web-phase2-read-only-ui.md
@@ -0,0 +1,70 @@
+# Web Phase 2 Read-Only UI
+
+## Status
+
+- `completed`
+
+## Owner
+
+- Codex
+
+## Started At
+
+- `2026-03-20`
+
+## Goal
+
+- Implement the first real Phase 2 operator UI in `apps/web` by shipping the read-only runs list, run detail, blocked queue, and thread timeline views on top of the existing `orchd` HTTP API and Cadence UI source-owned components.
+
+## Scope
+
+- create an execution trace for the Phase 2 read-only UI workstream
+- wire the frontend to the existing read-only API contract
+- implement routed views for runs list, run detail, blocked queue, and thread timeline
+- reuse Cadence UI source-owned components instead of introducing new foundational UI primitives
+- validate the frontend build and keep `docs/implementation-roadmap.md` synchronized
+
+## Checklist
+
+- [x] create the active execution roadmap for the Phase 2 read-only UI workstream
+- [x] inspect the current frontend shell and backend read contract
+- [x] add the frontend data layer and route structure for the Phase 2 read-only views
+- [x] implement the runs list, run detail, blocked queue, and thread timeline screens
+- [x] ensure Cadence UI styles render correctly in the consumer app
+- [x] validate the frontend build and fix any type or integration issues
+- [x] update `docs/implementation-roadmap.md`
+- [x] archive this roadmap with a completion summary if the slice is fully complete
+
+## Files
+
+- `docs/roadmaps/archive/web-phase2-read-only-ui.md`
+- `docs/implementation-roadmap.md`
+- `apps/web/package.json`
+- `apps/web/src/app.tsx`
+- `apps/web/src/features/operator-console.tsx`
+- `apps/web/src/lib/api.ts`
+- `apps/web/src/lib/format.ts`
+- `apps/web/src/styles.css`
+- `apps/web/src/vite-env.d.ts`
+- `apps/web/vite.config.ts`
+- `pnpm-lock.yaml`
+
+## Decisions
+
+- keep the first Phase 2 UI slice read-only and route it against the existing HTTP API instead of coupling UI delivery to new backend mutations
+- prefer composition from the copied-in Cadence UI source over new hand-rolled UI primitives
+
+## Blockers
+
+- none
+
+## Next Step
+
+- build the next web slice on top of the shipped read-only screens by adding operator write actions, council views, and realtime event handling without replacing the new routed operator shell
+
+## Completion Summary
+
+- replaced the placeholder `apps/web` landing screen with a routed read-only operator UI that now ships runs list, run detail, blocked queue, and thread timeline pages backed by the existing `orchd` API
+- added a typed frontend read layer for the current HTTP contract, including client-side blocked queue aggregation across runs and thread timeline payload/artifact rendering
+- wired Tailwind v4 into the consumer app so the copied-in Cadence UI source-owned components render correctly, and used those components throughout the new pages instead of introducing new foundational UI primitives
+- validated the slice with `pnpm run web:build`, seeded a local demo run/thread dataset through the existing `orch` and `inbox` CLIs, and verified the Vite dev server can proxy the runs, run detail, and thread endpoints through to `orchd`
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a547c45..00d056d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,12 @@ importers:
specifier: ^3.5.0
version: 3.5.0
devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.2.2
+ version: 4.2.2(vite@8.0.1(@types/node@24.12.0)(jiti@2.6.1))
+ '@types/node':
+ specifier: ^24.5.2
+ version: 24.12.0
'@types/react':
specifier: ^19.2.14
version: 19.2.14
@@ -55,13 +61,16 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@8.0.1)
+ version: 6.0.1(vite@8.0.1(@types/node@24.12.0)(jiti@2.6.1))
+ tailwindcss:
+ specifier: ^4.2.2
+ version: 4.2.2
typescript:
specifier: ^5.9.3
version: 5.9.3
vite:
specifier: ^8.0.1
- version: 8.0.1
+ version: 8.0.1(@types/node@24.12.0)(jiti@2.6.1)
packages:
@@ -74,6 +83,22 @@ packages:
'@emnapi/wasi-threads@1.2.0':
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.5.5':
+ resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
@@ -403,6 +428,96 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
+ '@tailwindcss/node@4.2.2':
+ resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
+
+ '@tailwindcss/oxide-android-arm64@4.2.2':
+ resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.2.2':
+ resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.2.2':
+ resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/vite@4.2.2':
+ resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7 || ^8
+
'@tanstack/history@1.161.6':
resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==}
engines: {node: '>=20.19'}
@@ -439,6 +554,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/node@24.12.0':
+ resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
+
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -484,6 +602,10 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ enhanced-resolve@5.20.1:
+ resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
+ engines: {node: '>=10.13.0'}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -516,10 +638,17 @@ packages:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
isbot@5.1.36:
resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==}
engines: {node: '>=18'}
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -590,6 +719,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
@@ -696,6 +828,13 @@ packages:
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
+ tailwindcss@4.2.2:
+ resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
+
+ tapable@2.3.0:
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ engines: {node: '>=6'}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -714,6 +853,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -800,6 +942,25 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.5.5': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.5
+
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.9.1
@@ -1061,6 +1222,74 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {}
+ '@tailwindcss/node@4.2.2':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.20.1
+ jiti: 2.6.1
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.2.2
+
+ '@tailwindcss/oxide-android-arm64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.2.2':
+ optional: true
+
+ '@tailwindcss/oxide@4.2.2':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.2.2
+ '@tailwindcss/oxide-darwin-arm64': 4.2.2
+ '@tailwindcss/oxide-darwin-x64': 4.2.2
+ '@tailwindcss/oxide-freebsd-x64': 4.2.2
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2
+ '@tailwindcss/oxide-linux-arm64-musl': 4.2.2
+ '@tailwindcss/oxide-linux-x64-gnu': 4.2.2
+ '@tailwindcss/oxide-linux-x64-musl': 4.2.2
+ '@tailwindcss/oxide-wasm32-wasi': 4.2.2
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
+ '@tailwindcss/oxide-win32-x64-msvc': 4.2.2
+
+ '@tailwindcss/vite@4.2.2(vite@8.0.1(@types/node@24.12.0)(jiti@2.6.1))':
+ dependencies:
+ '@tailwindcss/node': 4.2.2
+ '@tailwindcss/oxide': 4.2.2
+ tailwindcss: 4.2.2
+ vite: 8.0.1(@types/node@24.12.0)(jiti@2.6.1)
+
'@tanstack/history@1.161.6': {}
'@tanstack/query-core@5.91.2': {}
@@ -1105,6 +1334,10 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/node@24.12.0':
+ dependencies:
+ undici-types: 7.16.0
+
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@@ -1113,10 +1346,10 @@ snapshots:
dependencies:
csstype: 3.2.3
- '@vitejs/plugin-react@6.0.1(vite@8.0.1)':
+ '@vitejs/plugin-react@6.0.1(vite@8.0.1(@types/node@24.12.0)(jiti@2.6.1))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 8.0.1
+ vite: 8.0.1(@types/node@24.12.0)(jiti@2.6.1)
aria-hidden@1.2.6:
dependencies:
@@ -1136,6 +1369,11 @@ snapshots:
detect-node-es@1.1.0: {}
+ enhanced-resolve@5.20.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -1154,8 +1392,12 @@ snapshots:
get-nonce@1.0.1: {}
+ graceful-fs@4.2.11: {}
+
isbot@5.1.36: {}
+ jiti@2.6.1: {}
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1205,6 +1447,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.5.5
+
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
@@ -1302,6 +1548,10 @@ snapshots:
tailwind-merge@3.5.0: {}
+ tailwindcss@4.2.2: {}
+
+ tapable@2.3.0: {}
+
tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {}
@@ -1315,6 +1565,8 @@ snapshots:
typescript@5.9.3: {}
+ undici-types@7.16.0: {}
+
use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
dependencies:
react: 19.2.4
@@ -1334,7 +1586,7 @@ snapshots:
dependencies:
react: 19.2.4
- vite@8.0.1:
+ vite@8.0.1(@types/node@24.12.0)(jiti@2.6.1):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.3
@@ -1342,4 +1594,6 @@ snapshots:
rolldown: 1.0.0-rc.10
tinyglobby: 0.2.15
optionalDependencies:
+ '@types/node': 24.12.0
fsevents: 2.3.3
+ jiti: 2.6.1