feat(web): build read-only operator views

This commit is contained in:
2026-03-20 11:56:29 +08:00
parent ce9061ca54
commit f58f48f0d5
12 changed files with 1997 additions and 211 deletions
+3
View File
@@ -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"
}
+153 -56
View File
@@ -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 (
<div className="shell">
<header className="masthead">
<div>
<p className="eyebrow">AI Workflow Skill</p>
<Link className="brand" to="/">
Orch Control Plane
</Link>
<div className="relative min-h-screen overflow-hidden" data-theme="brand">
<div className="pointer-events-none fixed inset-0">
<div className="absolute inset-x-0 top-0 h-[38rem] bg-[radial-gradient(circle_at_top,oklch(0.9_0.06_174/.72),transparent_60%)]" />
<div className="absolute inset-y-0 right-0 w-[32rem] bg-[radial-gradient(circle_at_center,oklch(0.82_0.14_88/.18),transparent_56%)]" />
<div className="absolute inset-0 opacity-35 [background-image:linear-gradient(to_right,color-mix(in_oklch,var(--color-border)_46%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklch,var(--color-border)_46%,transparent)_1px,transparent_1px)] [background-size:72px_72px]" />
</div>
<div className="relative mx-auto flex min-h-screen w-full max-w-[1500px] flex-col gap-6 px-4 py-4 lg:flex-row lg:px-6 lg:py-6">
<aside className="hidden lg:flex lg:w-[18rem] lg:shrink-0">
<div className="flex h-[calc(100vh-3rem)] w-full flex-col rounded-[2rem] border border-[color-mix(in_oklch,var(--color-border)_80%,white)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] p-5 shadow-[var(--shadow-md)] backdrop-blur">
<div className="space-y-4">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-[var(--color-primary)]">
AI Workflow Skill
</p>
<div className="space-y-3">
<Link
className="type-display text-[2.4rem] leading-[0.92] tracking-[-0.05em] text-[var(--color-foreground)]"
to="/"
>
Orch Control Plane
</Link>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Read-only operator surfaces for runs, blocked work, and thread history.
</p>
</div>
</div>
<nav className="mt-8 grid gap-2">
<NavLink exact label="Runs overview" to="/" />
<NavLink label="Blocked queue" to="/blocked" />
</nav>
<div className="mt-auto rounded-[1.5rem] border border-[color-mix(in_oklch,var(--color-border)_78%,transparent)] bg-[color-mix(in_oklch,var(--color-surface)_82%,white_18%)] p-4">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.24em] text-[var(--color-muted-foreground)]">
Current slice
</p>
<ul className="mt-3 grid gap-2 text-sm leading-6 text-[var(--color-foreground)]">
<li>Runs list with health counts</li>
<li>Run detail with task board</li>
<li>Global blocked queue</li>
<li>Thread timeline and artifacts</li>
</ul>
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col gap-4">
<header className="rounded-[1.75rem] border border-[color-mix(in_oklch,var(--color-border)_80%,white)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] px-5 py-4 shadow-[var(--shadow-sm)] backdrop-blur lg:hidden">
<div className="flex flex-col gap-4">
<div className="space-y-2">
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-[var(--color-primary)]">
AI Workflow Skill
</p>
<Link
className="type-display block text-[2rem] leading-[0.94] tracking-[-0.05em] text-[var(--color-foreground)]"
to="/"
>
Orch Control Plane
</Link>
</div>
<div className="flex flex-wrap gap-2">
<NavLink exact label="Runs overview" to="/" />
<NavLink label="Blocked queue" to="/blocked" />
</div>
</div>
</header>
<main className="min-w-0 flex-1">
<Outlet />
</main>
</div>
<p className="masthead-copy">
Phase 1 keeps the UI thin while the backend contract settles around
`orchd`.
</p>
</header>
<main className="content">
<Outlet />
</main>
</div>
</div>
);
}
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 (
<section className="hero-grid">
<article className="hero-card hero-card-primary">
<p className="eyebrow">Current slice</p>
<h1>Read-only operator shell for a future multi-user web product.</h1>
<p className="lede">
The monorepo now has a dedicated React app, a Go HTTP service, and a
first API contract for runs, blocked work, and thread history.
</p>
</article>
<article className="hero-card hero-card-secondary">
<h2>Backend spine</h2>
<ul className="detail-list">
<li>`cmd/orchd` serves `chi` routes against the existing SQLite state.</li>
<li>`internal/query` shapes run, blocked-task, and thread reads.</li>
<li>`api/openapi.yaml` is the contract anchor for future typed clients.</li>
</ul>
</article>
<article className="hero-card">
<h2>Frontend posture</h2>
<p>
React, Vite, TanStack Router, and TanStack Query are present now so
Phase 2 can focus on actual operator views instead of build plumbing.
</p>
</article>
<article className="hero-card">
<h2>Next UI targets</h2>
<ul className="detail-list">
<li>Runs dashboard with task-count health signals.</li>
<li>Run detail board grouped by orchestration state.</li>
<li>Blocked queue and thread timeline wired to live refresh.</li>
</ul>
</article>
</section>
<Link
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3.5 py-2 text-sm font-medium transition-colors',
'border-[color-mix(in_oklch,var(--color-border)_76%,transparent)] bg-transparent text-[var(--color-muted-foreground)]',
'hover:border-[var(--color-border-strong)] hover:bg-[color-mix(in_oklch,var(--color-surface)_78%,white_22%)] hover:text-[var(--color-foreground)]',
isActive &&
'border-[color-mix(in_oklch,var(--color-primary)_32%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_12%,white)] text-[var(--color-primary)]',
)}
to={to}
>
<span className="h-1.5 w-1.5 rounded-full bg-current" />
{label}
</Link>
);
}
function RunDetailRoute() {
const { runId } = runDetailRoute.useParams();
return <RunDetailPage runId={runId} />;
}
function ThreadTimelineRoute() {
const { threadId } = threadTimelineRoute.useParams();
return <ThreadTimelinePage threadId={threadId} />;
}
File diff suppressed because it is too large Load Diff
+198
View File
@@ -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<string, number>;
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<string, number>;
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<T>(path: string): Promise<T> {
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));
}
+70
View File
@@ -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<string, unknown>).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<T>(items: T[], select: (item: T) => string | undefined) {
const values = items
.map(select)
.filter((value): value is string => Boolean(value));
return new Set(values).size;
}
+15 -133
View File
@@ -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);
}
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ORCH_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+6 -3
View File
@@ -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,
},
},
});