Rebuild the operator console on the ethos shell

Nine screens against orchestra-ui-spec.md and the accepted mockups, on
the ethos design system: signal violet #8F7AE5, the routing fork motif,
the 64px rail and 56px top bar, mono for every machine value and sans
for every human one.

Each screen was built by its own agent against a fixed foundation, so
the shell, tokens and primitives have one author and the screens cannot
drift into nine dialects.

Real data only. Where no endpoint exists the screen says which one it
needs instead of inventing a value. That is most of what was learned
here: Steer / Correct is disabled because nothing records a human
decision from the web, take-control is disabled because nothing
forwards keystrokes to a pane, context occupancy is missing from three
screens, and projects can show no repo, remote, quality gate or
verification policy because those live only in config.jsonc.

Three bugs the render caught that no computed value would have:

The previous stylesheet fought every shared class name and leaked
properties the new rules never mention, which is how position:fixed
survived on .topbar. It is now scoped under .legacy and applies only to
the login route, which also stops its green accent and its
backdrop-filter from reaching the console.

Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty
does not omit a struct, so absent timestamps arrived populated-looking
and rendered as "739855d ago". Stripped once at the API boundary every
screen reads through, with a test.

Long machine ids overflowed their cards and painted under the next one.

Verified by rendering: chromium screenshots of the dashboard, tasks,
task detail, terminal, workers and review at 1440px, and the dashboard
at 390px. Geist is still not on disk, so both stacks fall back to the
system faces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-29 02:44:43 +04:00
parent 118ac9fbcb
commit ffba6f0bab
39 changed files with 6958 additions and 573 deletions
+1
View File
@@ -16,3 +16,4 @@ node_modules/
.node_modules/
web/dist/
web/tsconfig.tsbuildinfo
build/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+107
View File
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
ETHOS — icon set
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
pause, prev, next, more) are the only filled exceptions.
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/> <symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
</svg>
color + width/height come from the consumer; the icon inherits them.
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
same corner feel. Match the existing hand — do not import lucide or
any other pack. Keep ids prefixed i- and kebab-cased.
-->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<!-- ── app mark / motif seeds ── -->
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
</symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
</symbol>
<!-- ── navigation ── -->
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
</symbol>
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
</symbol>
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
</symbol>
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
</symbol>
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
</symbol>
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
</symbol>
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
</symbol>
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
</symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
</symbol>
<!-- ── transport (filled) ── -->
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
</symbol>
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
</symbol>
<!-- ── actions / status ── -->
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
</symbol>
<symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
</svg>
+13 -3
View File
@@ -1,3 +1,13 @@
<script type="module" crossorigin src="/assets/index-C0H-zYgt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B0BkAbwp.css">
<div id="root"></div>
<!doctype html>
<html lang="en" data-app="orchestra">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>orchestra</title>
<script type="module" crossorigin src="/assets/index-BAiY1a7C.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BUuDvtvX.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+12 -1
View File
@@ -1 +1,12 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
<!doctype html>
<html lang="en" data-app="orchestra">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>orchestra</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
ETHOS — icon set
One hand, one grid. Every icon: viewBox 0 0 24 24, stroke="currentColor",
stroke-width 1.7, round caps/joins, fill="none". Transport glyphs (play,
pause, prev, next, more) are the only filled exceptions.
USE: <svg class="icon" width="22" height="22"><use href="/ethos-icons.svg#i-search"/> <symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
</svg>
color + width/height come from the consumer; the icon inherits them.
EXTEND: add a new <symbol id="i-NAME"> on the same 24px grid, same stroke,
same corner feel. Match the existing hand — do not import lucide or
any other pack. Keep ids prefixed i- and kebab-cased.
-->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<!-- ── app mark / motif seeds ── -->
<symbol id="i-wave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
<path d="M2 12h1M6 8v8M10 4v16M14 7v10M18 5v14M21 11v2"/>
</symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/>
<rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>
</symbol>
<!-- ── navigation ── -->
<symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11l8-6 8 6M6 10v9h12v-9"/>
</symbol>
<symbol id="i-listen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 18V9l14-3v9"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="15" r="2.4"/>
</symbol>
<symbol id="i-library" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<rect x="4" y="4" width="6" height="16" rx="1.5"/><rect x="14" y="4" width="6" height="16" rx="1.5"/>
</symbol>
<symbol id="i-album" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="2"/>
</symbol>
<symbol id="i-artist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/><path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6"/>
</symbol>
<symbol id="i-queue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h11M4 12h11M4 17h7M18 9v8"/><circle cx="18" cy="18.5" r="1.6"/>
</symbol>
<symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<path d="M4 7a1 1 0 011-1h4.5l2 2H19a1 1 0 011 1v8a1 1 0 01-1 1H5a1 1 0 01-1-1z"/>
</symbol>
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round">
<path d="M14 3H7a1 1 0 00-1 1v16a1 1 0 001 1h10a1 1 0 001-1V8z"/><path d="M14 3v5h5"/>
</symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3.2"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
</symbol>
<!-- ── transport (filled) ── -->
<symbol id="i-play" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5h3v14H8zM13 5h3v14h-3z"/></symbol>
<symbol id="i-prev" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6z"/><path d="M20 6v12l-9-6z"/></symbol>
<symbol id="i-next" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2z"/><path d="M6 6v12l9-6z"/></symbol>
<symbol id="i-shuffle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h4v4M20 4l-6 6M4 20l16-16M16 20h4v-4M14 14l6 6M4 4l4 4"/>
</symbol>
<symbol id="i-repeat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 2l3 3-3 3M20 5H8a4 4 0 00-4 4v1M7 22l-3-3 3-3M4 19h12a4 4 0 004-4v-1"/>
</symbol>
<!-- ── actions / status ── -->
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/>
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4.5 4.5L20 7"/></symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9a6 6 0 0112 0c0 5 2 6 2 6H4s2-1 2-6M10 21h4"/>
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v11M8 11l4 4 4-4M5 20h14"/>
</symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 15V4M8 8l4-4 4 4M5 20h14"/>
</symbol>
<symbol id="i-fork" viewBox="0 0 24 24"><path d="M4 12h4M8 12l4-5h8M8 12l4 5h8"/><circle cx="4" cy="12" r="1.6"/><circle cx="20" cy="7" r="1.6"/><circle cx="20" cy="17" r="1.6"/></symbol>
<symbol id="i-task" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8.5 12l2.5 2.5 4.5-5"/></symbol>
<symbol id="i-decision" viewBox="0 0 24 24"><path d="M12 3l9 9-9 9-9-9z"/><path d="M12 8v4M12 15.5v.5"/></symbol>
<symbol id="i-worker" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></symbol>
<symbol id="i-project" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
<symbol id="i-review" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></symbol>
<symbol id="i-terminal" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 9.5l3 2.5-3 2.5M13 15h4"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4l9 15H3z"/><path d="M12 10v4M12 16.5v.5"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4l-1.4 1.4"/></symbol>
</svg>
+60
View File
@@ -0,0 +1,60 @@
import { Route, Routes } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { Shell } from './shell/Shell'
import { Dashboard } from './screens/Dashboard'
import { Tasks } from './screens/Tasks'
import { TaskDetail } from './screens/TaskDetail'
import { Terminal } from './screens/Terminal'
import { Decisions } from './screens/Decisions'
import { Workers } from './screens/Workers'
import { Projects } from './screens/Projects'
import { Review } from './screens/Review'
import { Settings } from './screens/Settings'
import { api } from './api/client'
import type { Account } from './api/types'
/** The operator console. Auth stays in main.tsx; this is only what an
* authenticated operator sees. */
export function Console({
account,
onLogout,
onCredentialsChanged,
}: {
account: Account
onLogout: () => void
onCredentialsChanged: (username: string) => void
}) {
const overview = useQuery({
queryKey: ['overview'],
queryFn: api.overview,
refetchInterval: 5000,
})
return (
<Routes>
{/* No revision in the top bar yet. The overview carries worker builds,
not the coordinator's, and labelling a worker build as the
coordinator's would be a lie in the one place an operator trusts. */}
<Route element={<Shell live={!overview.isError} />}>
<Route index element={<Dashboard />} />
<Route path="tasks" element={<Tasks />} />
<Route path="tasks/:id" element={<TaskDetail />} />
<Route path="tasks/:id/terminal" element={<Terminal />} />
<Route path="decisions" element={<Decisions />} />
<Route path="workers" element={<Workers />} />
<Route path="projects" element={<Projects />} />
<Route path="review" element={<Review />} />
<Route
path="settings"
element={
<Settings
account={account}
onLogout={onLogout}
onCredentialsChanged={onCredentialsChanged}
/>
}
/>
</Route>
</Routes>
)
}
+20
View File
@@ -33,4 +33,24 @@ describe('UI API client',()=>{
await api.updateAccount({current_password:'old password',username:'kami',new_password:'new password'})
expect(fetch).toHaveBeenCalledWith('/v1/ui/account',expect.objectContaining({method:'PUT'}))
})
it('a zero Go timestamp is stripped rather than rendered as a date', async () => {
const fetchMock = vi.fn(async () =>
new Response(
JSON.stringify({
tasks: [{ id: 't', blocked_at: '0001-01-01T00:00:00Z', at: '2026-08-29T00:00:00Z' }],
workers: [],
sessions: [],
updated_at: '2026-08-29T00:00:00Z',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
vi.stubGlobal('fetch', fetchMock)
const overview = await api.overview()
const task = overview.tasks[0] as unknown as Record<string, unknown>
expect(task.blocked_at).toBeUndefined()
expect(task.at).toBe('2026-08-29T00:00:00Z')
expect(overview.updated_at).toBe('2026-08-29T00:00:00Z')
})
})
+28 -2
View File
@@ -1,4 +1,4 @@
import type { Account, CreatedEvent, Detail, Overview } from './types'
import type { Account, CreatedEvent, DebtLedger, Detail, Event, Overview, Worker } from './types'
function sessionExpired(response: Response) {
if (response.status === 401) {
@@ -11,6 +11,27 @@ async function responseError(response: Response) {
return new Error(message || `${response.status} ${response.statusText}`)
}
/** Go marshals a zero time.Time as "0001-01-01T00:00:00Z", and omitempty does
* not omit a struct, so an absent timestamp arrives populated-looking. Every
* screen that formatted one rendered "739855d ago", which reads as data.
* Stripping it here is one guard on the boundary every screen reads through,
* rather than one guard per screen per field. */
function stripZeroTimes(value: unknown): unknown {
if (typeof value === 'string') {
return value.startsWith('0001-01-01') ? undefined : value
}
if (Array.isArray(value)) return value.map(stripZeroTimes)
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const cleaned = stripZeroTimes(v)
if (cleaned !== undefined) out[k] = cleaned
}
return out
}
return value
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
credentials: 'same-origin',
@@ -23,7 +44,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
sessionExpired(response)
throw await responseError(response)
}
return response.json() as Promise<T>
return stripZeroTimes(await response.json()) as T
}
async function text(path: string) {
@@ -93,6 +114,11 @@ export const api = {
method: 'POST',
body: JSON.stringify(body),
}),
// Endpoints outside /v1/ui that the console reads directly. Adding a UI
// wrapper for each would be a second copy of the same projection.
workers: () => request<Worker[]>('/v1/federation/workers'),
events: (since = 0) => request<Event[]>(`/v1/events?since=${since}`),
debt: () => request<DebtLedger>('/v1/debt'),
action: (id: string, action: string, body: object = {}) =>
request<Detail>(`/v1/ui/tasks/${id}/actions/${action}`, {
method: 'POST',
+68
View File
@@ -35,7 +35,27 @@ export interface SessionEvidence {
checked_at?: string
}
/** Mirrors domain.DecisionRequest. Fields are optional here because a
* projection may omit them, and a screen must never assume a question. */
export interface DecisionOption {
id?: string
description?: string
tradeoff?: string
}
export interface DecisionRequest {
question?: string
why?: string
options?: DecisionOption[]
evidence?: string[]
}
export interface Task {
/** On the wire from domain.Task. Three screens widened these locally
* before they lived here. */
work_phase?: string
last_lease_epoch?: string
decision_request?: DecisionRequest
id: string
source: string
external_id: string
@@ -122,7 +142,17 @@ export interface Detail {
actions: Action[]
}
/** One distinct worker failure with its repeat count. The count is the
* signal: a repeat means something looped rather than merely broke. */
export interface Observation {
message: string
count: number
first: string
last: string
}
export interface WorkerHealth {
observations?: Observation[]
backend?: 'herdr' | 'tmux'
herdr_status: 'reachable' | 'unreachable' | 'unknown'
checked_at?: string
@@ -154,3 +184,41 @@ export interface CreatedEvent {
task_id: string
id: string
}
/** The read-only debt projection. Items are candidates derived from history,
* and gaps name the evidence the log cannot carry at all. */
export interface DebtObservation {
event_id?: string
legacy_ref?: string
task_id?: string
kind: string
signature: string
detail?: string
paths?: string[]
at: string
}
export interface DebtItem {
id: string
class: 'correctness' | 'operational' | 'structural' | 'polish'
status: string
symptom: string
consequence?: string
severity?: string
signatures: string[]
paths?: string[]
observations: DebtObservation[]
}
export interface EvidenceGap {
kind: string
reason: string
durable: boolean
}
export interface DebtLedger {
items: DebtItem[]
gaps: EvidenceGap[]
events: number
eligible?: { item: DebtItem; check: { eligible: boolean; reasons?: string[] } }[]
}
+19
View File
@@ -0,0 +1,19 @@
/** One sprite, one hand. Never import a generic icon pack: that breaks the
* single-hand rule the ethos set exists to hold. */
export function Icon({ name, size = 20 }: { name: string; size?: number }) {
return (
<svg
width={size}
height={size}
aria-hidden="true"
focusable="false"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
>
<use href={`/ethos-icons.svg#i-${name}`} />
</svg>
)
}
+133
View File
@@ -0,0 +1,133 @@
import type { ReactNode } from 'react'
import { Icon } from './Icon'
/** Machine values are mono, always. A label is human, its value is not. */
export function M({ children }: { children: ReactNode }) {
return <span className="mono">{children}</span>
}
export function Panel({
title,
count,
trailing,
children,
bodied = false,
}: {
title?: string
count?: number
trailing?: ReactNode
children: ReactNode
bodied?: boolean
}) {
return (
<section className="panel">
{title && (
<header className="panel-head">
<h2>{title}</h2>
{count !== undefined && <span className="count">{count}</span>}
{trailing && <span className="trailing">{trailing}</span>}
</header>
)}
{bodied ? <div className="panel-body">{children}</div> : children}
</section>
)
}
export type Health = 'healthy' | 'degraded' | 'error' | 'unknown'
export function Dot({ health }: { health: Health }) {
return <span className="dot" data-health={health} />
}
export function Chip({
tone,
children,
}: {
tone?: 'accent' | 'warn' | 'fault' | 'done'
children: ReactNode
}) {
return (
<span className="chip" data-tone={tone}>
{children}
</span>
)
}
export function Meter({ value }: { value: number }) {
const pct = Math.max(0, Math.min(100, Math.round(value)))
return (
<span className="meter" role="img" aria-label={`${pct} percent`}>
<span style={{ width: `${pct}%` }} />
</span>
)
}
/** The ace-fca path, drawn as the routing fork. Completed phases read quiet
* green, the current phase carries the accent, the future stays neutral. */
export const PHASES = ['frame', 'research', 'plan', 'implement', 'review', 'pr', 'merged'] as const
export type Phase = (typeof PHASES)[number]
export function PhasePath({ current, labels = true }: { current?: string; labels?: boolean }) {
const at = PHASES.indexOf((current || 'frame') as Phase)
return (
<div className="phases">
{PHASES.map((phase, i) => {
const state = i < at ? 'done' : i === at ? 'current' : 'todo'
return (
<span key={phase} style={{ display: 'flex', alignItems: 'center' }}>
{i > 0 && <span className="phase-link" data-state={i <= at ? 'done' : 'todo'} />}
<span className="phase-step" data-state={state}>
<span className="phase-node" />
{labels && <span className="phase-name">{phase}</span>}
</span>
</span>
)
})}
</div>
)
}
export function Empty({ title, children }: { title: string; children?: ReactNode }) {
return (
<div className="empty">
<span className="motif">
<Icon name="fork" size={28} />
</span>
<h3>{title}</h3>
{children}
</div>
)
}
/** A screen with no endpoint says which one it needs. The interface never
* implies data it does not have. */
export function EndpointGap({ path, what }: { path: string; what: string }) {
return (
<div className="panel-body">
<p className="gap-note">
no endpoint yet: {path}
</p>
<p style={{ color: 'var(--text-mid)', marginBottom: 0 }}>{what}</p>
</div>
)
}
/** Go marshals a zero time.Time as "0001-01-01T00:00:00Z", and omitempty does
* not omit a struct. Every timestamp off the wire can therefore arrive
* populated-looking and mean absent. Without this guard a screen prints
* "739000d ago" and reads as data. */
export function absent(at?: string) {
return !at || at.startsWith('0001-01-01')
}
/** A relative age, or null when the timestamp is absent. Never a fabricated
* duration. */
export function ago(at?: string): string | null {
if (absent(at)) return null
const seconds = Math.round((Date.now() - new Date(at as string).getTime()) / 1000)
if (!Number.isFinite(seconds)) return null
if (seconds < 60) return `${Math.max(0, seconds)}s ago`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
return `${Math.floor(seconds / 86400)}d ago`
}
+5 -3
View File
@@ -32,6 +32,8 @@ import type {
Worker,
} from './api/types'
import './style.css'
import './styles/orchestra.css'
import { Console } from './Console'
const client = new QueryClient({
defaultOptions: {
@@ -1755,7 +1757,7 @@ function Login({ onAuthenticated, message }: { onAuthenticated: (account: Accoun
}
return (
<main className="login-page">
<main className="legacy login-page">
<div className="login-grid" aria-hidden="true" />
<div className="login-layout">
<section className="login-showcase" aria-label="Orchestra overview">
@@ -1889,7 +1891,7 @@ function App() {
if (checking) {
return (
<main className="login-page">
<main className="legacy login-page">
<section className="session-check" aria-live="polite">
<Logo /><span className="spinner" /><span>Checking your operator session</span>
</section>
@@ -1898,7 +1900,7 @@ function App() {
}
return account
? <RoutesApp account={account} onLogout={logout} onCredentialsChanged={credentialsChanged} />
? <Console account={account} onLogout={logout} onCredentialsChanged={credentialsChanged} />
: <Login message={message} onAuthenticated={(authenticated) => { client.clear(); setMessage(''); setAccount(authenticated) }} />
}
+126
View File
@@ -0,0 +1,126 @@
/* Dashboard-only geometry. Everything structural (panel, row, table, stat,
chip, meter) comes from orchestra.css and is not restated here. */
.head-stamp {
margin-left: auto;
font-size: 12px;
color: var(--text-lo);
}
.stamp-fault {
color: #e06c60;
}
/* The attention mark is a square of the row's own tone a signal, never a
wash across the row. */
.att-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: none;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--text-mid);
}
.att-mark[data-kind='fault'] {
border-color: rgba(216, 166, 87, 0.3);
background: rgba(216, 166, 87, 0.1);
color: #d8a657;
}
.att-mark[data-kind='review'] {
border-color: var(--accent-line);
background: var(--accent-dim);
color: var(--accent-hi);
}
.att-task {
min-width: 0;
flex: 1;
}
.att-when {
font-size: 12px;
white-space: nowrap;
}
.row-main,
.att-task,
.cell-link {
display: flex;
flex-direction: column;
}
.row-title,
.row-sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-link {
color: inherit;
}
.phase-cell {
display: flex;
align-items: center;
gap: 12px;
}
.table td .mono[data-expired] {
color: #e06c60;
}
.worker-lines {
display: flex;
flex-direction: column;
margin-top: 14px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.worker-line {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border-bottom: 1px solid var(--line);
font-size: 12px;
transition: background var(--fast) var(--ease);
}
.worker-line:last-child {
border-bottom: 0;
}
.worker-line:hover {
background: var(--bg-2);
}
.worker-seen {
margin-left: auto;
}
.worker-errors {
margin-top: 10px;
}
.worker-errors p {
margin: 0 0 4px;
font-size: 12px;
}
@media (max-width: 640px) {
.att-task,
.att-when {
display: none;
}
.worker-line {
flex-wrap: wrap;
}
}
+361
View File
@@ -0,0 +1,361 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { BlockReason, Overview, Session, Task, Worker } from '../api/types'
import { Chip, Dot, Empty, EndpointGap, M, Panel, PhasePath } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Dashboard.css'
/* The ace-fca phase lives on the task as work_phase; the shared Task type has
not caught up with the Go domain yet, so it is read through a narrow cast
rather than invented or renamed. */
const phaseOf = (t: Task) => (t as Task & { work_phase?: string }).work_phase
const now = () => Date.now()
/** "18s ago" / "2m ago" / "1h ago" — a machine value, so it renders mono. */
function ago(at?: string) {
// Go marshals a zero time.Time as "0001-01-01T00:00:00Z" and omitempty does
// not omit a struct, so an absent timestamp arrives populated-looking. Left
// unguarded this rendered "739855d ago", which reads as data.
if (!at || at.startsWith('0001-01-01')) return undefined
const s = Math.max(0, Math.round((now() - Date.parse(at)) / 1000))
if (!Number.isFinite(s)) return undefined
if (s < 60) return `${s}s ago`
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
/** Time remaining on a lease as mm:ss, or "expired" when it already ran out. */
function until(at?: string) {
if (!at) return undefined
const s = Math.round((Date.parse(at) - now()) / 1000)
if (!Number.isFinite(s)) return undefined
if (s <= 0) return 'expired'
const m = Math.floor(s / 60)
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
const shortId = (id: string) => (id.length > 14 ? `${id.slice(0, 6)}${id.slice(-4)}` : id)
/* Why an item is on the operator's desk. Every line is derived from a real
block_reason or state none of it is decorative copy. */
const attentionReason: Record<BlockReason, string> = {
trajectory_gate: 'A trajectory gate is waiting for your steering.',
human_decision: 'The agent asked for a decision it may not make itself.',
operator_required: 'Orchestra cannot proceed without an operator action.',
approval: 'A tool or edit approval is pending.',
handoff_validation: 'The handoff failed validation and was not accepted.',
lease_failure: 'The lease could not be established.',
lease_expired: 'The lease expired before the agent finished.',
worker_offline: 'The worker holding this task went offline.',
system_error: 'Orchestra hit an error it could not retry past.',
operator_block: 'You blocked this task.',
unknown: 'Blocked for a reason orchestra could not classify.',
}
const stateReason: Partial<Record<Task['state'], string>> = {
in_review: 'A pull request is waiting on review.',
failed: 'The task failed and will not retry on its own.',
needs_attention: 'Orchestra flagged this task for you.',
blocked: 'The task is blocked.',
}
function reasonFor(t: Task) {
if (t.block_reason && attentionReason[t.block_reason]) return attentionReason[t.block_reason]
return stateReason[t.state] ?? 'Waiting on you.'
}
const needsOperator = (t: Task) =>
t.state === 'needs_attention' || t.state === 'blocked' || t.state === 'failed' || t.state === 'in_review'
/** Sessions carry no task id of their own unless a capture is attached, so
* they are matched on the pane the task last held. */
function sessionFor(t: Task, sessions: Session[]) {
return sessions.find(
(s) =>
s.capture?.task_id === t.id ||
(t.last_pane_id !== undefined && s.pane_id === t.last_pane_id) ||
(t.lease !== undefined && s.harness_id === t.lease.harness_id),
)
}
function attentionIcon(t: Task) {
if (t.state === 'in_review') return 'review'
if (t.block_reason === 'trajectory_gate') return 'fork'
if (t.block_reason === 'human_decision' || t.block_reason === 'approval') return 'decision'
return 'alert'
}
function Attention({ tasks }: { tasks: Task[] }) {
return (
<Panel
title="Needs your attention"
count={tasks.length}
trailing={<Link to="/tasks">View all</Link>}
>
{tasks.length === 0 ? (
<Empty title="Nothing is waiting on you">
<p>Blocked tasks, trajectory gates and open reviews land here.</p>
</Empty>
) : (
<div className="rows">
{tasks.map((t) => (
<Link className="row" key={t.id} to={`/tasks/${t.id}`}>
<span className="att-mark" data-kind={t.state === 'in_review' ? 'review' : 'fault'}>
<Icon name={attentionIcon(t)} size={18} />
</span>
<span className="row-main">
<span className="row-title">{t.title || 'Untitled task'}</span>
<span className="row-sub">{reasonFor(t)}</span>
</span>
<span className="att-task">
<span className="row-title">{t.title ? t.project : t.id}</span>
<span className="row-sub">
<M>{shortId(t.id)}</M>
{phaseOf(t) && (
<>
{' · '}
<M>{phaseOf(t)}</M>
</>
)}
</span>
</span>
{t.blocked_at && (
<span className="mono att-when">{ago(t.blocked_at)}</span>
)}
<Chip tone={t.state === 'in_review' ? 'accent' : 'warn'}>
{t.block_reason ?? t.state}
</Chip>
<Icon name="chevron-right" size={16} />
</Link>
))}
</div>
)}
</Panel>
)
}
function Running({ tasks, sessions }: { tasks: Task[]; sessions: Session[] }) {
return (
<Panel
title="Running tasks"
count={tasks.length}
trailing={<Link to="/tasks">View all tasks</Link>}
>
{tasks.length === 0 ? (
<Empty title="No task holds a lease">
<p>Leased sessions appear here with their phase, worker and lease clock.</p>
</Empty>
) : (
<div className="table-scroll">
<table className="table">
<thead>
<tr>
<th>Task</th>
<th>Phase</th>
<th>Worker / harness</th>
<th>Last progress</th>
<th>Lease ends in</th>
<th>Attempt</th>
<th />
</tr>
</thead>
<tbody>
{tasks.map((t) => {
const s = sessionFor(t, sessions)
const left = until(t.lease?.until)
return (
<tr key={t.id}>
<td>
<Link className="cell-link" to={`/tasks/${t.id}`}>
<span className="row-title">{t.title || 'Untitled task'}</span>
<span className="row-sub">
<M>{shortId(t.id)}</M>
</span>
</Link>
</td>
<td>
<div className="phase-cell">
<Chip tone="accent">{phaseOf(t) ?? 'unknown'}</Chip>
<PhasePath current={phaseOf(t)} />
</div>
</td>
<td>
<span className="row-title mono">{t.lease?.harness_id ?? '—'}</span>
<span className="row-sub">
<M>{s?.pane_id ?? t.last_pane_id ?? 'pane unknown'}</M>
</span>
</td>
<td>
{s?.capture?.at ? (
<>
<span className="mono">{ago(s.capture.at)}</span>
<span className="row-sub">
capture <M>r{s.capture.revision}</M>
</span>
</>
) : (
<span className="row-sub">no capture yet</span>
)}
</td>
<td>
<span className="mono" data-expired={left === 'expired' ? '' : undefined}>
{left ?? '—'}
</span>
{s?.agent_status && <span className="row-sub">{s.agent_status}</span>}
</td>
<td>
<M>{t.attempt ?? 1}</M>
</td>
<td>
<Link className="cell-link" to={`/tasks/${t.id}`} aria-label="Open task">
<Icon name="chevron-right" size={16} />
</Link>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<EndpointGap
path="GET /v1/ui/overview → session.context"
what="Context occupancy per session is measured by the herdr adapter but is not carried on the overview projection, so this table shows lease time instead of a context meter."
/>
</Panel>
)
}
function Capacity({ workers, tasks }: { workers: Worker[]; tasks: Task[] }) {
const online = workers.filter((w) => w.online).length
const reachable = workers.filter((w) => w.health.herdr_status === 'reachable').length
const unreachable = workers.filter((w) => w.health.herdr_status === 'unreachable').length
const leased = tasks.filter((t) => t.lease)
const expired = leased.filter((t) => until(t.lease?.until) === 'expired').length
const erroring = workers.filter((w) => w.health.last_error)
return (
<Panel title="System state and capacity" trailing={<Link to="/workers">View workers</Link>}>
<div className="panel-body">
<div className="grid stats">
<div className="stat">
<span className="label">Workers online</span>
<span className="stat-value">
{online} / {workers.length}
</span>
<span className="row-sub">
<M>{workers.reduce((n, w) => n + w.capacity, 0)}</M> total capacity
</span>
</div>
<div className="stat">
<span className="label">Active leases</span>
<span className="stat-value">{leased.length}</span>
<span className="row-sub">
<M>{expired}</M> past their lease end
</span>
</div>
<div className="stat">
<span className="label">Herdr backends</span>
<span className="stat-value">
{reachable} / {workers.length}
</span>
<span className="row-sub">
<M>{unreachable}</M> unreachable, from worker health
</span>
</div>
</div>
<div className="worker-lines">
{workers.length === 0 ? (
<p className="row-sub">No worker has ever registered.</p>
) : (
workers.map((w) => (
<Link className="worker-line" key={w.id} to="/workers">
<Dot
health={
!w.online
? 'error'
: w.health.herdr_status === 'reachable'
? 'healthy'
: w.health.herdr_status === 'unreachable'
? 'degraded'
: 'unknown'
}
/>
<span className="mono">{w.id}</span>
<span className="row-sub">{w.health.backend ?? 'backend unknown'}</span>
<span className="mono worker-seen">seen {ago(w.last_seen) ?? '—'}</span>
{w.health.active_task_id && (
<span className="mono">{shortId(w.health.active_task_id)}</span>
)}
{w.build?.revision && <span className="mono">{w.build.revision.slice(0, 7)}</span>}
</Link>
))
)}
</div>
{erroring.length > 0 && (
<div className="worker-errors">
{erroring.map((w) => (
<p key={w.id}>
<span className="mono">{w.id}</span>{' '}
<span className="row-sub">{w.health.last_error}</span>{' '}
<span className="mono">{ago(w.health.error_at) ?? ''}</span>
</p>
))}
</div>
)}
</div>
<EndpointGap
path="GET /v1/quota, GET /v1/router, GET /v1/sources"
what="Quota headroom, router queue depth and rejections, and source reconciliation state have no endpoint. Nothing here stands in for them."
/>
</Panel>
)
}
export function Dashboard() {
const overview = useQuery<Overview>({
queryKey: ['overview'],
queryFn: api.overview,
refetchInterval: 5000,
})
const workers = useQuery<Worker[]>({
queryKey: ['workers'],
queryFn: api.workers,
refetchInterval: 5000,
})
const tasks = overview.data?.tasks ?? []
const sessions = overview.data?.sessions ?? []
// Workers come from the federation registry; the overview snapshot is the
// fallback when that call has not landed or is failing.
const workerList = workers.data ?? overview.data?.workers ?? []
return (
<main className="page">
<div className="page-head">
<h1>Dashboard</h1>
<p>What needs you, what is running, and whether the system is healthy.</p>
<span className="head-stamp">
{overview.data ? (
<>
read <M>{new Date(overview.data.updated_at).toISOString().slice(11, 19)} UTC</M>
</>
) : overview.isError ? (
<span className="stamp-fault">overview unreachable {String(overview.error)}</span>
) : (
'reading the first snapshot'
)}
</span>
</div>
<Attention tasks={tasks.filter(needsOperator)} />
<Running tasks={tasks.filter((t) => t.state === 'leased')} sessions={sessions} />
<Capacity workers={workerList} tasks={tasks} />
</main>
)
}
+206
View File
@@ -0,0 +1,206 @@
/* Decisions durable human authority.
Only what the shared sheet does not already carry: the filter bar, the
standing/superseded split, and the expanded detail row. */
.dec-filters {
display: flex;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
}
.dec-search {
flex: 1;
min-width: 200px;
height: 32px;
padding: 0 11px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
color: var(--text-hi);
font: inherit;
outline: none;
transition: border-color var(--fast) var(--ease);
}
.dec-search:hover,
.dec-search:focus {
border-color: var(--line-hi);
}
.dec-filter {
display: flex;
flex-direction: column;
gap: 3px;
}
.dec-filter select {
height: 32px;
padding: 0 8px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
color: var(--text-hi);
font: inherit;
cursor: pointer;
}
/* Operator authority is the prominent class; history is muted and never
dominant. The accent marks the standing decision, it does not wash the row. */
.dec-table tbody tr[data-status] {
cursor: pointer;
}
.dec-table tbody tr[data-status='active'] td:first-child {
box-shadow: inset 2px 0 0 var(--accent);
}
.dec-table tbody tr[data-status='waiting'] td:first-child {
box-shadow: inset 2px 0 0 #d8a657;
}
.dec-table tbody tr[data-status='superseded'] .row-title,
.dec-table tbody tr[data-status='superseded'] .row-sub,
.dec-table tbody tr[data-status='superseded'] .mono {
color: var(--text-lo);
}
.dec-table tbody tr[data-status='superseded'] .row-title {
text-decoration: line-through;
text-decoration-color: var(--line-hi);
}
.dec-table tbody tr[data-open='true'] {
background: var(--bg-2);
}
.dec-table .row-title {
max-width: 42ch;
}
.dec-task {
display: flex;
flex-direction: column;
}
.dec-task:hover .mono {
color: var(--accent-hi);
}
.dec-task .row-sub {
max-width: 22ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── detail ──────────────────────────────────────────────────────────── */
.dec-detail-row td {
background: var(--bg-2);
border-top: 1px solid var(--line-hi);
}
.dec-detail {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.1fr);
gap: 24px;
padding: 6px 4px 10px;
}
.dec-detail .label {
display: block;
margin-bottom: 4px;
}
.dec-detail p {
margin: 0 0 14px;
}
/* The human decision text stays sans, and reads as the loudest thing here. */
.dec-value {
font-size: 15px;
color: var(--text-hi);
}
.dec-subject {
color: var(--text-mid);
}
.dec-impact {
color: var(--accent-hi);
}
.dec-impact[data-retired='true'] {
color: var(--text-lo);
}
.dec-facts {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: baseline;
gap: 4px 14px;
font-size: 12px;
}
.dec-facts .label {
margin: 0;
}
.dec-facts > div {
min-width: 0;
overflow-wrap: anywhere;
}
.dec-link {
background: none;
border: 0;
padding: 0;
cursor: pointer;
color: var(--accent-hi);
font-size: 12px;
display: block;
text-align: left;
}
.dec-trail {
min-width: 0;
}
.dec-event {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 11px;
padding: 3px 0;
border-bottom: 1px solid var(--line);
overflow: hidden;
}
.dec-event:last-child {
border-bottom: 0;
}
.dec-event-type {
color: var(--text-mid);
white-space: nowrap;
}
.dec-event .mono:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-value[data-accent='true'] {
color: var(--accent-hi);
}
@media (max-width: 900px) {
.dec-detail {
grid-template-columns: minmax(0, 1fr);
gap: 14px;
}
}
+458
View File
@@ -0,0 +1,458 @@
import { Fragment, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Event, Task } from '../api/types'
import { Chip, Empty, EndpointGap, M, Panel } from '../components/Primitives'
import './Decisions.css'
/** The payload of a HumanDecisionRecorded event. There is no decisions
* endpoint, so this screen reduces the log the way domain.ReduceIntent does:
* a decision stands until something names it. */
interface DecisionPayload {
decision_id?: string
kind?: string
subject?: string
value?: string
supersedes?: string[]
source?: { provider?: string; external_id?: string }
}
/** The coordinator serves these on the task; api/types.ts does not model them
* yet, so the shapes this screen needs are narrowed here rather than assumed. */
interface BlockedTask extends Task {
work_phase?: string
decision_request?: { question?: string; why?: string }
}
interface Decision {
id: string
taskId: string
kind: string
subject: string
value: string
supersedes: string[]
provider: string
externalId: string
at: string
eventId: string
seq?: number
}
/** A question the operator has not answered yet. It is not a decision it
* has no id and no value but it is the same authority, pending. */
interface Waiting {
taskId: string
question: string
why: string
at: string
}
function payload(event: Event): DecisionPayload {
return (event.payload ?? {}) as DecisionPayload
}
function stamp(at: string) {
return at.replace('T', ' ').slice(0, 19) + ' UTC'
}
function shortId(id: string) {
return id.length > 8 ? id.slice(-6) : id
}
interface Reduced {
decisions: Decision[]
/** decision id -> the decision that retired it, or '' for a standalone
* HumanDecisionSuperseded event with no successor. */
retiredBy: Map<string, string>
events: Map<string, Event[]>
}
function reduce(events: Event[]): Reduced {
const decisions: Decision[] = []
const retiredBy = new Map<string, string>()
const byDecision = new Map<string, Event[]>()
const touch = (id: string, event: Event) => {
const list = byDecision.get(id)
if (list) list.push(event)
else byDecision.set(id, [event])
}
for (const event of events) {
const p = payload(event)
if (!p.decision_id) continue
if (event.type === 'HumanDecisionRecorded') {
const supersedes = p.supersedes ?? []
decisions.push({
id: p.decision_id,
taskId: event.task_id ?? '',
kind: p.kind ?? '',
subject: p.subject ?? '',
value: p.value ?? '',
supersedes,
provider: p.source?.provider ?? '',
externalId: p.source?.external_id ?? '',
at: event.at,
eventId: event.id,
seq: event.seq,
})
touch(p.decision_id, event)
for (const target of supersedes) {
retiredBy.set(target, p.decision_id)
touch(target, event)
}
} else if (event.type === 'HumanDecisionSuperseded') {
if (!retiredBy.has(p.decision_id)) retiredBy.set(p.decision_id, '')
touch(p.decision_id, event)
}
}
decisions.sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))
return { decisions, retiredBy, events: byDecision }
}
function Select({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: string[]
onChange: (next: string) => void
}) {
return (
<label className="dec-filter">
<span className="label">{label}</span>
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">all</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
)
}
export function Decisions() {
const events = useQuery({ queryKey: ['events'], queryFn: () => api.events(), refetchInterval: 5000 })
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
const [status, setStatus] = useState('')
const [kind, setKind] = useState('')
const [project, setProject] = useState('')
const [source, setSource] = useState('')
const [query, setQuery] = useState('')
const [open, setOpen] = useState<string>()
const tasks = useMemo(() => {
const map = new Map<string, BlockedTask>()
for (const task of (overview.data?.tasks ?? []) as BlockedTask[]) map.set(task.id, task)
return map
}, [overview.data])
const { decisions, retiredBy, events: trail } = useMemo(
() => reduce(events.data ?? []),
[events.data],
)
const waiting = useMemo<Waiting[]>(
() =>
[...tasks.values()]
.filter((t) => t.block_reason === 'human_decision' && t.decision_request)
.map((t) => ({
taskId: t.id,
question: t.decision_request?.question ?? '',
why: t.decision_request?.why ?? '',
at: t.blocked_at ?? '',
})),
[tasks],
)
const kinds = useMemo(
() => [...new Set(decisions.map((d) => d.kind).filter(Boolean))].sort(),
[decisions],
)
const sources = useMemo(
() => [...new Set(decisions.map((d) => d.provider).filter(Boolean))].sort(),
[decisions],
)
const projects = useMemo(
() => [...new Set([...tasks.values()].map((t) => t.project).filter(Boolean))].sort(),
[tasks],
)
const needle = query.trim().toLowerCase()
const matches = (d: Decision) => {
const task = tasks.get(d.taskId)
const retired = retiredBy.has(d.id)
if (status === 'active' && retired) return false
if (status === 'superseded' && !retired) return false
if (status === 'waiting') return false
if (kind && d.kind !== kind) return false
if (source && d.provider !== source) return false
if (project && task?.project !== project) return false
if (needle) {
const hay = [d.id, d.taskId, d.subject, d.value, task?.title ?? ''].join(' ').toLowerCase()
if (!hay.includes(needle)) return false
}
return true
}
const visible = decisions.filter(matches)
const visibleWaiting = waiting.filter((w) => {
if (status && status !== 'waiting') return false
if (kind || source) return false
if (project && tasks.get(w.taskId)?.project !== project) return false
if (needle) return [w.taskId, w.question, w.why].join(' ').toLowerCase().includes(needle)
return true
})
const active = decisions.length - retiredBy.size
const loading = events.isLoading || overview.isLoading
return (
<main className="page">
<div className="page-head">
<h1>Decisions</h1>
<p>Durable human authority: what the operator decided, and what still stands.</p>
</div>
<div className="grid stats">
<div className="stat">
<span className="label">Recorded</span>
<span className="stat-value">{decisions.length}</span>
</div>
<div className="stat">
<span className="label">Standing</span>
<span className="stat-value" data-accent="true">
{active}
</span>
</div>
<div className="stat">
<span className="label">Superseded</span>
<span className="stat-value">{retiredBy.size}</span>
</div>
<div className="stat">
<span className="label">Waiting on you</span>
<span className="stat-value">{waiting.length}</span>
</div>
</div>
<Panel
title="Decision log"
count={visible.length + visibleWaiting.length}
trailing={
events.isError || overview.isError ? 'read failed' : `reduced from ${(events.data ?? []).length} events`
}
>
<div className="dec-filters">
<input
className="dec-search"
placeholder="Search decisions…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Select
label="Status"
value={status}
options={['active', 'superseded', 'waiting']}
onChange={setStatus}
/>
<Select label="Kind" value={kind} options={kinds} onChange={setKind} />
<Select label="Project" value={project} options={projects} onChange={setProject} />
<Select label="Source" value={source} options={sources} onChange={setSource} />
</div>
{loading ? (
<div className="panel-body">
<p className="gap-note">reading /v1/events</p>
</div>
) : visible.length + visibleWaiting.length === 0 ? (
<Empty title="No decisions match">
<p>
The log holds <M>{decisions.length}</M> recorded decisions. Clear the filters to see
them.
</p>
</Empty>
) : (
<div className="table-scroll">
<table className="table dec-table">
<thead>
<tr>
<th>ID</th>
<th>Decision</th>
<th>Task</th>
<th>Phase</th>
<th>Provenance</th>
<th>Recorded</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{visibleWaiting.map((w) => (
<tr key={`w-${w.taskId}`} data-status="waiting">
<td className="mono"></td>
<td>
<div className="row-title">{w.question}</div>
<div className="row-sub">{w.why}</div>
</td>
<td>
<Link className="dec-task" to={`/tasks/${w.taskId}`}>
<M>{shortId(w.taskId)}</M>
<span className="row-sub">{tasks.get(w.taskId)?.title ?? ''}</span>
</Link>
</td>
<td className="mono">{tasks.get(w.taskId)?.work_phase ?? '—'}</td>
<td className="mono"></td>
<td className="mono">{w.at ? stamp(w.at) : '—'}</td>
<td>
<Chip tone="warn">waiting</Chip>
</td>
</tr>
))}
{visible.map((d) => {
const retired = retiredBy.has(d.id)
const successor = retiredBy.get(d.id)
const task = tasks.get(d.taskId)
const isOpen = open === d.id
return (
<Fragment key={d.id}>
<tr
data-status={retired ? 'superseded' : 'active'}
data-open={isOpen ? 'true' : undefined}
onClick={() => setOpen(isOpen ? undefined : d.id)}
>
<td className="mono" title={d.id}>
{shortId(d.id)}
</td>
<td>
<div className="row-title">{d.value}</div>
<div className="row-sub">{d.subject}</div>
</td>
<td>
<Link
className="dec-task"
to={`/tasks/${d.taskId}`}
onClick={(e) => e.stopPropagation()}
>
<M>{shortId(d.taskId)}</M>
<span className="row-sub">{task?.title ?? ''}</span>
</Link>
</td>
<td className="mono">{task?.work_phase ?? '—'}</td>
<td className="mono" title={d.externalId}>
{d.provider || '—'}
</td>
<td className="mono">{stamp(d.at)}</td>
<td>
{retired ? (
<Chip>superseded</Chip>
) : (
<Chip tone="accent">{d.kind || 'active'}</Chip>
)}
</td>
</tr>
{isOpen && (
<tr className="dec-detail-row">
<td colSpan={7}>
<div className="dec-detail">
<div>
<span className="label">Value</span>
<p className="dec-value">{d.value}</p>
<span className="label">Subject</span>
<p className="dec-subject">{d.subject || '—'}</p>
<span className="label">Effective authority</span>
<p className="dec-impact" data-retired={retired ? 'true' : undefined}>
{retired
? successor
? 'Retired. It is out of the effective intent for this task.'
: 'Retired by an explicit supersede. It no longer binds the agent.'
: 'Standing. It is in the effective intent handed to every session of this task.'}
</p>
</div>
<div className="dec-facts">
<span className="label">Decision id</span>
<div>
<M>{d.id}</M>
</div>
<span className="label">Kind</span>
<div>
<M>{d.kind}</M>
</div>
<span className="label">Task</span>
<div>
<Link to={`/tasks/${d.taskId}`}>
<M>{d.taskId}</M>
</Link>
</div>
<span className="label">Provenance</span>
<div>
<M>{d.provider || 'unknown'}</M>{' '}
{d.externalId && <M>· {d.externalId}</M>}
</div>
<span className="label">Supersedes</span>
<div>
{d.supersedes.length === 0 ? (
<M>none</M>
) : (
d.supersedes.map((s) => (
<button
key={s}
className="dec-link mono"
onClick={() => setOpen(s)}
>
{s}
</button>
))
)}
</div>
<span className="label">Superseded by</span>
<div>
{!retired ? (
<M>none</M>
) : successor ? (
<button className="dec-link mono" onClick={() => setOpen(successor)}>
{successor}
</button>
) : (
<M>explicit supersede event</M>
)}
</div>
</div>
<div className="dec-trail">
<span className="label">Event sequence</span>
{(trail.get(d.id) ?? []).map((e) => (
<div key={e.id} className="dec-event">
<M>{e.seq !== undefined ? `#${e.seq}` : '—'}</M>
<span className="dec-event-type">{e.type}</span>
<M>{stamp(e.at)}</M>
<M>{e.id}</M>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
{/* Law 5: the mockup's approval columns have no source in the log. */}
<EndpointGap
path="/v1/ui/decisions"
what="Recorded, superseded and waiting are reduced from the event log. Approved, rejected and auto are not: the domain has no approval state on a decision, so those statuses are omitted rather than guessed. The acting human is not recorded either — source.provider is the channel the decision arrived on, not the person. Phase is the task's current work phase, not the phase the decision was made in."
/>
</Panel>
</main>
)
}
+16
View File
@@ -0,0 +1,16 @@
/* Projects: two columns on desktop, stacked on mobile. Everything else comes
from orchestra.css this file exists only for the reflow. */
.projects-split {
grid-template-columns: minmax(0, 1fr) minmax(0, 340px);
}
/* .row is a flex container; its title/subtitle stack. */
.projects-split .row-main > span {
display: block;
}
@media (max-width: 900px) {
.projects-split {
grid-template-columns: minmax(0, 1fr);
}
}
+296
View File
@@ -0,0 +1,296 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Task, TaskState, Worker } from '../api/types'
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
import './Projects.css'
/** Orchestra has no projects endpoint. The registry that defines a project
* repo, remote, quality gate, verification policy, machine affinity lives in
* the coordinator's config.jsonc and is never served over HTTP. What this
* screen shows is derived from work that actually exists: the tasks in
* /v1/ui/overview carry a project, and the workers in the federation registry
* declare which projects they will accept. Everything else is a stated gap,
* not a plausible-looking guess. */
const ATTENTION: TaskState[] = ['needs_attention', 'blocked', 'failed']
interface ProjectRow {
id: string
tasks: Task[]
counts: Record<TaskState, number>
attention: number
active: number
/** Newest capture/check the coordinator has for this project's sessions.
* A task carries no updated_at, so this is the only honest recency signal
* the overview projection actually contains. */
lastSeen?: string
workers: Worker[]
}
function fmt(at?: string) {
if (!at) return undefined
const d = new Date(at)
return Number.isNaN(d.getTime()) ? at : `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
}
function newest(a?: string, b?: string) {
if (!a) return b
if (!b) return a
return a > b ? a : b
}
function derive(tasks: Task[], workers: Worker[]): ProjectRow[] {
const rows = new Map<string, ProjectRow>()
for (const task of tasks) {
const id = task.project || '(unset)'
let row = rows.get(id)
if (!row) {
row = {
id,
tasks: [],
counts: {} as Record<TaskState, number>,
attention: 0,
active: 0,
workers: workers.filter((w) => (w.supported_projects || []).includes(id)),
}
rows.set(id, row)
}
row.tasks.push(task)
row.counts[task.state] = (row.counts[task.state] || 0) + 1
if (ATTENTION.includes(task.state)) row.attention++
if (task.state === 'leased') row.active++
row.lastSeen = newest(
row.lastSeen,
newest(task.last_session?.captured_at, task.last_session?.checked_at),
)
}
return [...rows.values()].sort(
(a, b) => b.attention - a.attention || b.active - a.active || a.id.localeCompare(b.id),
)
}
function stateChips(counts: Record<TaskState, number>) {
const order: TaskState[] = [
'leased',
'queued',
'in_review',
'needs_attention',
'blocked',
'failed',
'completed',
]
return order
.filter((state) => counts[state])
.map((state) => (
<Chip
key={state}
tone={
state === 'leased'
? 'accent'
: state === 'completed'
? 'done'
: state === 'needs_attention'
? 'warn'
: ATTENTION.includes(state)
? 'fault'
: undefined
}
>
{state.replace('_', ' ')} <M>{counts[state]}</M>
</Chip>
))
}
export function Projects() {
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
const [selected, setSelected] = useState<string>()
const rows = useMemo(
() => derive(overview.data?.tasks || [], overview.data?.workers || []),
[overview.data],
)
const current = rows.find((r) => r.id === selected) || rows[0]
return (
<main className="page">
<div className="page-head">
<h1>Projects</h1>
<p>
Projects orchestra is holding work for, derived from the tasks it knows about. The
coordinator does not serve its project registry.
</p>
</div>
{overview.isError && (
<Panel>
<div className="panel-body">
<p className="gap-note">/v1/ui/overview failed: {String(overview.error)}</p>
</div>
</Panel>
)}
{!overview.data && !overview.isError && (
<Panel>
<div className="panel-body">
<p className="gap-note">reading /v1/ui/overview</p>
</div>
</Panel>
)}
{overview.data && rows.length === 0 && (
<Panel>
<Empty title="No project has work">
<p>
Every project here comes from a task. Orchestra holds{' '}
<M>{overview.data.tasks.length}</M> tasks, so nothing groups yet.
</p>
</Empty>
</Panel>
)}
{current && (
<div className="grid projects-split">
<Panel title="With work" count={rows.length}>
<div className="table-scroll">
<table className="table">
<thead>
<tr>
<th>Project</th>
<th>Tasks</th>
<th>Leased</th>
<th>Attention</th>
<th>Last session evidence</th>
<th>Workers</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
onClick={() => setSelected(row.id)}
style={{
cursor: 'pointer',
background: row.id === current.id ? 'var(--bg-2)' : undefined,
boxShadow:
row.id === current.id ? 'inset 3px 0 0 0 var(--accent)' : undefined,
}}
>
<td>
<M>{row.id}</M>
</td>
<td>
<M>{row.tasks.length}</M>
</td>
<td>
<M>{row.active}</M>
</td>
<td>
{row.attention ? (
<Chip tone="fault">
<M>{row.attention}</M>
</Chip>
) : (
<M>0</M>
)}
</td>
<td>
<M>{fmt(row.lastSeen) || '—'}</M>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{row.workers.length === 0 ? (
<M>none</M>
) : (
row.workers.map((w) => (
<Chip key={w.id}>
<Dot health={w.online ? 'healthy' : 'error'} />
<M>{w.id}</M>
</Chip>
))
)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
<div className="grid" style={{ alignContent: 'start' }}>
<Panel
title={current.id}
trailing={<Link to={`/tasks?project=${encodeURIComponent(current.id)}`}>Tasks </Link>}
>
<div className="panel-body" style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{stateChips(current.counts)}
</div>
<div>
<div className="label">Workers declaring this project</div>
<p style={{ margin: '4px 0 0', color: 'var(--text-mid)' }}>
{current.workers.length === 0
? 'No registered worker accepts it. Orchestra does not treat an omitted declaration as a wildcard, so nothing can lease this work.'
: 'Worker-declared, from the federation registry — not the coordinators project affinity.'}
</p>
{current.workers.map((w) => (
<div key={w.id} style={{ marginTop: 6 }}>
<Dot health={w.online ? 'healthy' : 'error'} /> <M>{w.id}</M>{' '}
<M>{w.address || ''}</M>
</div>
))}
</div>
</div>
</Panel>
<Panel title="Tasks" count={current.tasks.length}>
<div className="rows">
{current.tasks.slice(0, 8).map((task) => (
<Link key={task.id} className="row" to={`/tasks/${task.id}`}>
<span className="row-main">
<span className="row-title">{task.title || task.external_id || 'untitled'}</span>
<span className="row-sub">
<M>{task.id}</M>
</span>
</span>
<Chip
tone={
task.state === 'leased'
? 'accent'
: task.state === 'completed'
? 'done'
: task.state === 'needs_attention'
? 'warn'
: ATTENTION.includes(task.state)
? 'fault'
: undefined
}
>
{task.state.replace('_', ' ')}
</Chip>
</Link>
))}
</div>
{current.tasks.length > 8 && (
<div className="panel-body">
<Link to={`/tasks?project=${encodeURIComponent(current.id)}`}>
All <M>{current.tasks.length}</M> tasks
</Link>
</div>
)}
</Panel>
<Panel title="Project configuration">
<EndpointGap
path="/v1/ui/projects"
what="repo, remote, quality gate and verification policy live in the coordinator's config.jsonc and are not served over HTTP"
/>
</Panel>
</div>
</div>
)}
</main>
)
}
+195
View File
@@ -0,0 +1,195 @@
/* Review-only geometry. Panel, row, table, stat, chip and btn come from
orchestra.css and are not restated here. */
.rev-stamp {
margin-left: auto;
font-size: 12px;
color: var(--text-lo);
}
.rev-fault {
color: #e06c60;
}
.rev-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.rev-tabs .btn[data-active='true'] {
border-color: var(--accent-line);
background: var(--accent-dim);
color: var(--accent-hi);
}
/* One desk item: the row itself, plus whatever evidence it carries under it. */
.rev-item {
border-bottom: 1px solid var(--line);
}
.rev-item:last-child {
border-bottom: 0;
}
.rev-line {
display: flex;
align-items: center;
gap: 10px;
padding-right: 16px;
}
.rev-line .row.rev-link {
flex: 1;
min-width: 0;
border-bottom: 0;
}
.rev-out {
flex: none;
gap: 6px;
font-size: 12px;
}
/* The kind mark is a square of the item's own tone a signal, never a wash
across the row. */
.rev-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: none;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--text-mid);
}
.rev-mark[data-kind='trajectory'],
.rev-mark[data-kind='pr'] {
border-color: var(--accent-line);
background: var(--accent-dim);
color: var(--accent-hi);
}
.rev-mark[data-kind='decision'],
.rev-mark[data-kind='operator'] {
border-color: rgba(216, 166, 87, 0.3);
background: rgba(216, 166, 87, 0.1);
color: #d8a657;
}
.rev-mark[data-kind='findings'] {
border-color: rgba(224, 108, 96, 0.3);
background: rgba(224, 108, 96, 0.1);
color: #e06c60;
}
.rev-kind {
flex: none;
width: 88px;
font-size: 13px;
color: var(--text-mid);
}
.rev-task {
display: flex;
flex-direction: column;
min-width: 0;
flex: 0 0 168px;
}
.rev-meta {
display: flex;
flex-direction: column;
gap: 2px;
flex: none;
min-width: 72px;
font-size: 12px;
white-space: nowrap;
}
.row-main,
.rev-task {
display: flex;
flex-direction: column;
}
.rev-link .row-title,
.rev-link .row-sub,
.rev-task .row-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Refs sit under the row as verified orchestra state: neutral, mono values. */
.rev-refs {
display: flex;
flex-wrap: wrap;
gap: 6px 20px;
padding: 0 16px 12px 60px;
font-size: 12px;
color: var(--text-machine);
}
/* An agent claim is labelled as one. It is never given orchestra's voice. */
.claim-label {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 8px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.04em;
color: #d8a657;
}
.rev-findings,
.rev-options {
margin: 0 16px 14px 60px;
padding: 12px 14px;
border: 1px solid var(--line-hi);
border-left: 2px solid rgba(216, 166, 87, 0.45);
border-radius: var(--r-sm);
background: var(--bg-2);
}
.rev-options p {
margin: 0 0 6px;
font-size: 13px;
color: var(--text-hi);
}
.rev-findings pre {
margin: 0;
max-height: 340px;
overflow: auto;
font-size: 12px;
line-height: 1.55;
color: var(--text-mid);
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 640px) {
.rev-line {
flex-wrap: wrap;
padding: 0 16px 12px;
}
.rev-kind,
.rev-task {
width: auto;
flex: none;
}
.rev-refs,
.rev-findings,
.rev-options {
margin-left: 16px;
padding-left: 14px;
}
}
+426
View File
@@ -0,0 +1,426 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Overview, Task } from '../api/types'
import { Chip, Empty, EndpointGap, M, Panel } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Review.css'
/* The Go domain carries review, submission, decision_request and work_phase on
the task; the shared TS Task type has not caught up. They are read through a
narrow cast rather than invented, renamed, or added to a file this screen
does not own. Shapes are internal/domain/domain.go and submission.go. */
type ReviewRef = { artifact_ref: string; result_sha: string; blocking: number }
type ExternalRef = { provider: string; id: string; url?: string }
type SubmissionRef = {
result_sha: string
remote_ref?: string
pr: ExternalRef
gate_ref?: string
review_ref?: string
}
type DecisionOption = { id: string; description: string; tradeoff?: string }
type DecisionRequest = {
question: string
why: string
options?: DecisionOption[]
evidence?: string[]
}
type FullTask = Task & {
review?: ReviewRef
submission?: SubmissionRef
decision_request?: DecisionRequest
review_target_sha?: string
work_phase?: string
}
const full = (t: Task) => t as FullTask
/** "2m ago" — a machine value, so it renders mono at the call site. */
function ago(at?: string) {
if (!at) return undefined
const s = Math.round((Date.now() - Date.parse(at)) / 1000)
if (!Number.isFinite(s) || s < 0) return undefined
if (s < 60) return `${s}s ago`
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
const shortId = (id: string) => (id.length > 14 ? `${id.slice(0, 6)}${id.slice(-4)}` : id)
const shortSha = (sha?: string) => (sha ? sha.slice(0, 7) : undefined)
type Kind = 'trajectory' | 'decision' | 'operator' | 'findings' | 'pr'
const KINDS: { kind: Kind; label: string; icon: string }[] = [
{ kind: 'trajectory', label: 'Trajectory', icon: 'fork' },
{ kind: 'decision', label: 'Blocker', icon: 'decision' },
{ kind: 'operator', label: 'Operator', icon: 'alert' },
{ kind: 'findings', label: 'AI review', icon: 'review' },
{ kind: 'pr', label: 'Code review', icon: 'task' },
]
const meta = (k: Kind) => KINDS.find((x) => x.kind === k)!
type Item = {
key: string
kind: Kind
task: FullTask
/** Why this is on the desk, in the operator's language. Always derived. */
why: string
headline: string
at?: string
}
/* Reasons an operator-required block can carry. Each line names the real
block_reason it came from none of it is decorative copy. */
const operatorReason: Record<string, string> = {
operator_required: 'Orchestra cannot proceed without an operator action.',
approval: 'A tool or edit approval is pending in the pane.',
handoff_validation: 'The handoff failed validation and was not accepted.',
lease_failure: 'The lease could not be established.',
lease_expired: 'The lease expired before the agent finished.',
worker_offline: 'The worker holding this task went offline.',
system_error: 'Orchestra hit an error it could not retry past.',
operator_block: 'You blocked this task.',
unknown: 'Blocked for a reason orchestra could not classify.',
}
/** One task can be on the desk for more than one reason, and each reason is
* its own item: answering the blocker does not clear the review findings. */
function itemsFor(task: Task): Item[] {
const t = full(task)
const out: Item[] = []
const at = t.blocked_at
if (t.block_reason === 'trajectory_gate') {
out.push({
key: `${t.id}:trajectory`,
kind: 'trajectory',
task: t,
headline: t.decision_request?.question ?? 'Trajectory gate is held',
why:
t.decision_request?.why ??
t.blocker ??
'A trajectory gate is waiting for your steering before work continues.',
at,
})
}
if (t.block_reason === 'human_decision' || (t.decision_request && !out.length)) {
out.push({
key: `${t.id}:decision`,
kind: 'decision',
task: t,
headline: t.decision_request?.question ?? t.blocker ?? 'A decision is required',
why:
t.decision_request?.why ??
t.blocker ??
'The agent asked for a decision it may not make on its own.',
at,
})
}
if (t.state === 'blocked' && !out.length) {
const reason = t.block_reason ?? 'unknown'
out.push({
key: `${t.id}:operator`,
kind: 'operator',
task: t,
headline: t.blocker || `Blocked: ${reason}`,
why: operatorReason[reason] ?? operatorReason.unknown,
at,
})
}
if (t.state === 'needs_attention' && !out.length) {
out.push({
key: `${t.id}:operator`,
kind: 'operator',
task: t,
headline: t.blocker || t.last_error || 'Flagged for you',
why: 'Orchestra flagged this task and will not move it without you.',
at,
})
}
if (t.review && t.review.blocking > 0) {
const stale = t.review.result_sha !== (t.submission?.result_sha ?? t.review.result_sha)
out.push({
key: `${t.id}:findings`,
kind: 'findings',
task: t,
headline: `${t.review.blocking} blocking finding${t.review.blocking === 1 ? '' : 's'}`,
why: stale
? 'A sealed review claims blocking findings against a commit the submission has moved past. Accept or override them.'
: 'A sealed review claims blocking findings. They stand until you accept or override them.',
at,
})
}
if (t.submission?.pr) {
out.push({
key: `${t.id}:pr`,
kind: 'pr',
task: t,
headline: t.title || 'Change submitted for review',
why: 'A pull request is open and waiting on human review.',
at,
})
} else if (t.state === 'in_review' && !out.some((i) => i.kind === 'findings')) {
out.push({
key: `${t.id}:pr`,
kind: 'pr',
task: t,
headline: t.title || 'Awaiting review',
why: 'The task is in review with no submission recorded yet.',
at,
})
}
return out
}
/** The sealed review artifact. It is an agent claim until a human accepts it,
* so it is labelled as one and never rendered as orchestra's own verdict. */
function Findings({ artifactRef }: { artifactRef: string }) {
const q = useQuery({
queryKey: ['artifact', artifactRef],
queryFn: () => api.artifact(artifactRef),
staleTime: Infinity, // a sealed artifact never changes
})
return (
<div className="rev-findings">
<p className="claim-label">
<Icon name="alert" size={14} />
agent-supplied · sealed review artifact <M>{artifactRef}</M>
</p>
{q.isError ? (
<p className="rev-fault">could not read artifact: {String(q.error)}</p>
) : q.data === undefined ? (
<p className="row-sub">
reading <M>{artifactRef}</M>
</p>
) : (
<pre>{q.data}</pre>
)}
</div>
)
}
function ItemRow({ item }: { item: Item }) {
const [open, setOpen] = useState(false)
const t = item.task
const m = meta(item.kind)
const age = ago(item.at)
return (
<div className="rev-item">
<div className="rev-line">
<Link className="row rev-link" to={`/tasks/${t.id}`}>
<span className="rev-mark" data-kind={item.kind}>
<Icon name={m.icon} size={18} />
</span>
<span className="rev-kind">{m.label}</span>
<span className="row-main">
<span className="row-title">{item.headline}</span>
<span className="row-sub">{item.why}</span>
</span>
<span className="rev-task">
<span className="row-title">{t.project}</span>
<span className="row-sub">
<M>{shortId(t.id)}</M>
{t.work_phase && (
<>
{' · '}
<M>{t.work_phase}</M>
</>
)}
</span>
</span>
<span className="rev-meta">
<span className="label">priority</span>
<M>{t.inherent_priority ?? 0}</M>
</span>
<span className="rev-meta">
<span className="label">age</span>
{age ? <M>{age}</M> : <span className="row-sub">not timestamped</span>}
</span>
{item.kind === 'findings' && t.review && (
<Chip tone="fault">
<M>{t.review.blocking}</M> blocking
</Chip>
)}
{item.kind === 'pr' && t.submission?.pr?.id && (
<Chip tone="accent">
<M>{t.submission.pr.id}</M>
</Chip>
)}
<Icon name="chevron-right" size={16} />
</Link>
{item.kind === 'pr' && t.submission?.pr?.url && (
<a
className="btn rev-out"
href={t.submission.pr.url}
target="_blank"
rel="noreferrer"
>
<Icon name="upload" size={14} />
{t.submission.pr.provider}
</a>
)}
{item.kind === 'findings' && t.review && (
<button className="btn rev-out" onClick={() => setOpen(!open)}>
{open ? 'Hide findings' : 'Read findings'}
</button>
)}
</div>
{(item.kind === 'pr' || item.kind === 'findings') && (
<div className="rev-refs">
{t.review && (
<span>
<span className="label">reviewed sha</span> <M>{shortSha(t.review.result_sha)}</M>
</span>
)}
{t.submission && (
<span>
<span className="label">submitted sha</span>{' '}
<M>{shortSha(t.submission.result_sha)}</M>
</span>
)}
{t.submission?.gate_ref && (
<span>
<span className="label">gate</span> <M>{t.submission.gate_ref}</M>
</span>
)}
{t.submission?.review_ref && (
<span>
<span className="label">review</span> <M>{t.submission.review_ref}</M>
</span>
)}
</div>
)}
{item.kind === 'trajectory' && t.decision_request?.options?.length ? (
<div className="rev-options">
<p className="claim-label">
<Icon name="alert" size={14} />
agent-supplied · proposed options
</p>
{t.decision_request.options.map((o) => (
<p key={o.id}>
<M>{o.id}</M> {o.description}
{o.tradeoff && <span className="row-sub"> {o.tradeoff}</span>}
</p>
))}
</div>
) : null}
{open && item.kind === 'findings' && t.review && (
<Findings artifactRef={t.review.artifact_ref} />
)}
</div>
)
}
export function Review() {
const overview = useQuery<Overview>({
queryKey: ['overview'],
queryFn: api.overview,
refetchInterval: 5000,
})
const [kind, setKind] = useState<Kind | 'all'>('all')
const items = (overview.data?.tasks ?? []).flatMap(itemsFor)
// Most urgent first: high inherent priority, then oldest wait.
const order = KINDS.map((k) => k.kind)
items.sort(
(a, b) =>
(b.task.inherent_priority ?? 0) - (a.task.inherent_priority ?? 0) ||
Date.parse(a.at ?? '') - Date.parse(b.at ?? '') ||
order.indexOf(a.kind) - order.indexOf(b.kind),
)
const shown = kind === 'all' ? items : items.filter((i) => i.kind === kind)
const count = (k: Kind) => items.filter((i) => i.kind === k).length
const blocking = items.reduce((n, i) => n + (i.kind === 'findings' ? (i.task.review?.blocking ?? 0) : 0), 0)
return (
<main className="page">
<div className="page-head">
<h1>Review</h1>
<p>Items that need your attention and judgement.</p>
<span className="rev-stamp">
{overview.data ? (
<>
read <M>{new Date(overview.data.updated_at).toISOString().slice(11, 19)} UTC</M>
</>
) : overview.isError ? (
<span className="rev-fault">overview unreachable {String(overview.error)}</span>
) : (
'reading the first snapshot'
)}
</span>
</div>
<div className="grid stats">
<div className="stat">
<span className="label">Needs you</span>
<span className="stat-value">{items.length}</span>
<span className="row-sub">
across <M>{new Set(items.map((i) => i.task.id)).size}</M> tasks
</span>
</div>
<div className="stat">
<span className="label">Blocking findings</span>
<span className="stat-value">{blocking}</span>
<span className="row-sub">claimed by sealed reviews, not yet accepted</span>
</div>
<div className="stat">
<span className="label">Pull requests open</span>
<span className="stat-value">{count('pr')}</span>
<span className="row-sub">recorded on a submission</span>
</div>
</div>
<Panel title="Attention queue" count={shown.length}>
<div className="rev-tabs">
<button className="btn" data-active={kind === 'all'} onClick={() => setKind('all')}>
All <M>{items.length}</M>
</button>
{KINDS.map((k) => (
<button
key={k.kind}
className="btn"
data-active={kind === k.kind}
onClick={() => setKind(k.kind)}
>
{k.label} <M>{count(k.kind)}</M>
</button>
))}
</div>
{shown.length === 0 ? (
<Empty title={items.length === 0 ? 'Nothing is waiting on you' : 'Nothing of this type'}>
<p>
Trajectory gates, human decisions, operator-required blocks, blocking review findings
and open pull requests land here.
</p>
</Empty>
) : (
<div className="rows">
{shown.map((i) => (
<ItemRow key={i.key} item={i} />
))}
</div>
)}
</Panel>
<EndpointGap
path="GET /v1/ui/submissions → pr.state, GET /v1/ui/review/snoozes"
what="Two item types the spec lists have no source. A closed-but-unmerged pull request cannot be derived: submission.pr carries provider, id and url but no forge state, and orchestra never polls the forge. Informational/FYI items and snooze or defer state have no endpoint and no event either, so no tab stands in for them."
/>
</main>
)
}
+164
View File
@@ -0,0 +1,164 @@
/* Settings. Only what the shared shell does not carry: the tab strip, the
credential form geometry, and the fact list. */
.set-tabs {
display: flex;
gap: 2px;
flex-wrap: wrap;
border-bottom: 1px solid var(--line);
}
.set-tab {
background: none;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 8px 12px;
color: var(--text-mid);
cursor: pointer;
transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.set-tab:hover {
color: var(--text-hi);
}
/* Accent marks the section you are in, and the save button. Nothing else. */
.set-tab[aria-selected='true'] {
color: var(--accent-hi);
border-bottom-color: var(--accent);
}
.set-account {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.set-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.set-note {
margin: 0;
color: var(--text-mid);
font-size: 13px;
}
.set-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.set-split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
}
.set-input {
height: 34px;
padding: 0 11px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
color: var(--text-hi);
font: inherit;
outline: none;
width: 100%;
transition: border-color var(--fast) var(--ease);
}
.set-input::placeholder {
color: var(--text-lo);
}
.set-input:focus {
border-color: var(--accent-line);
}
.set-reveal {
position: relative;
display: flex;
}
.set-reveal-btn {
position: absolute;
right: 4px;
top: 4px;
height: 26px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: var(--bg-3);
color: var(--text-mid);
font-size: 12px;
cursor: pointer;
}
.set-rules {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-size: 12px;
color: var(--text-lo);
}
.set-rules span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.set-rules span[data-met='true'] {
color: #5fbf82;
}
.set-error {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
color: #e06c60;
}
.set-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
border-top: 1px solid var(--line);
padding-top: 14px;
}
.set-facts {
display: flex;
flex-direction: column;
gap: 12px;
}
.set-facts > div {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
word-break: break-word;
}
.set-signout {
align-self: flex-start;
margin-top: 4px;
}
@media (max-width: 900px) {
.set-account {
grid-template-columns: minmax(0, 1fr);
}
}
+256
View File
@@ -0,0 +1,256 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Account } from '../api/types'
import { Chip, EndpointGap, M, Panel } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Settings.css'
/** The spec asks for eight tabs. The coordinator has exactly one settings
* mutation PUT /v1/ui/account so exactly one tab holds controls. Every
* other tab names the endpoint that would supply it rather than showing a
* form that cannot save. Law 5. */
type TabKey =
| 'account'
| 'general'
| 'appearance'
| 'notifications'
| 'access'
| 'integrations'
| 'agents'
| 'policies'
| 'advanced'
const TABS: { key: TabKey; label: string }[] = [
{ key: 'account', label: 'Account' },
{ key: 'general', label: 'General' },
{ key: 'appearance', label: 'Appearance' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'access', label: 'Access & security' },
{ key: 'integrations', label: 'Integrations' },
{ key: 'agents', label: 'Agents & tools' },
{ key: 'policies', label: 'Policies' },
{ key: 'advanced', label: 'Advanced' },
]
const GAPS: Record<Exclude<TabKey, 'account'>, { path: string; what: string }> = {
general: {
path: 'GET/PUT /v1/ui/settings/general',
what: 'Instance name and environment, time display, default task preferences, default workflow policy and event/artifact retention. The coordinator keeps these in config.jsonc and the container environment; nothing reads or writes them over the API.',
},
appearance: {
path: 'GET/PUT /v1/ui/settings/appearance',
what: 'Theme and reduced motion are the only appearance choices ethos permits, and neither is stored server-side today. The console follows the operating system.',
},
notifications: {
path: 'GET/PUT /v1/ui/settings/notifications',
what: 'Notification sinks and per-event routing. ntfy credentials live in the compose .env file and are never returned by the API.',
},
access: {
path: 'GET /v1/ui/settings/access',
what: 'Web tokens and their status, trusted and ignored review actors, live operator sessions, agent-surface auth and the source each secret is loaded from. Only the operator credential below is exposed today.',
},
integrations: {
path: 'GET /v1/ui/settings/integrations',
what: 'Gitea and Vikunja endpoints, notification sinks and source reconciliation state, each with its identity and last successful interaction. No endpoint reports integration health.',
},
agents: {
path: 'GET /v1/ui/settings/harnesses',
what: 'Harness definitions, adapter runtime configuration, herdr and tmux ownership, default launch behaviour and context occupancy thresholds. The registry is read from config.jsonc at start-up and is not served.',
},
policies: {
path: 'GET/PUT /v1/ui/settings/policies',
what: 'Work phase defaults, the trajectory gate, the bounded decision request budget, retry policy, reconcile failure handoff threshold and review/submission policy. These are compiled-in defaults, not stored settings.',
},
advanced: {
path: 'GET /v1/ui/settings/diagnostics',
what: 'Event-store diagnostics, snapshots and replay, raw configuration, migration status and destructive operator actions. Destructive actions must not be built before the coordinator can report what they would touch.',
},
}
function stamp(at?: string) {
if (!at) return '—'
const ms = Date.parse(at)
return Number.isFinite(ms) ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : at
}
function message(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
export function Settings({ account, onLogout, onCredentialsChanged }: {
account: Account
onLogout: () => void
onCredentialsChanged: (username: string) => void
}) {
const [tab, setTab] = useState<TabKey>('account')
return (
<main className="page">
<div className="page-head">
<h1>Settings</h1>
<p>One operator credential is stored here. Everything else Orchestra runs on comes from config and environment.</p>
</div>
<div className="set-tabs" role="tablist" aria-label="Settings sections">
{TABS.map((t) => (
<button
key={t.key}
role="tab"
type="button"
className="set-tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
{tab === 'account' ? (
<AccountTab account={account} onLogout={onLogout} onCredentialsChanged={onCredentialsChanged} />
) : (
<Panel title={TABS.find((t) => t.key === tab)?.label}>
<EndpointGap path={GAPS[tab].path} what={GAPS[tab].what} />
</Panel>
)}
</main>
)
}
function AccountTab({ account, onLogout, onCredentialsChanged }: {
account: Account
onLogout: () => void
onCredentialsChanged: (username: string) => void
}) {
const [username, setUsername] = useState(account.username)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmation, setConfirmation] = useState('')
const [visible, setVisible] = useState(false)
const [formError, setFormError] = useState('')
const mutation = useMutation({
mutationFn: () =>
api.updateAccount({
current_password: currentPassword,
username: username.trim(),
...(newPassword ? { new_password: newPassword } : {}),
}),
onSuccess: (updated) => onCredentialsChanged(updated.username),
})
const changed = username.trim() !== account.username || !!newPassword
const longEnough = newPassword.length >= 10
const withinLimit = new TextEncoder().encode(newPassword).length <= 72
const matches = newPassword === confirmation
const submit = (event: React.FormEvent) => {
event.preventDefault()
setFormError('')
if (!username.trim()) return setFormError('Username cannot be empty.')
if (!changed) return setFormError('Change the username or enter a new password first.')
if (!currentPassword) return setFormError('Enter your current password to authorize this change.')
if (newPassword && (!longEnough || !withinLimit || !matches)) {
return setFormError(matches ? 'Use a password between 10 and 72 bytes.' : 'The new passwords do not match.')
}
mutation.mutate()
}
return (
<div className="set-account">
<Panel title="Sign-in credentials">
<form className="panel-body set-form" onSubmit={submit} noValidate>
<p className="set-note">
Changing either field signs out every browser using this account. Your current password authorizes the change and is never stored by the console.
</p>
<div className="set-field">
<label className="label" htmlFor="set-username">Username</label>
<input
id="set-username"
className="set-input"
autoComplete="username"
value={username}
onChange={(e) => { setUsername(e.target.value); setFormError('') }}
/>
</div>
<div className="set-split">
<div className="set-field">
<label className="label" htmlFor="set-new">New password</label>
<div className="set-reveal">
<input
id="set-new"
className="set-input"
type={visible ? 'text' : 'password'}
autoComplete="new-password"
placeholder="Leave blank to keep it"
value={newPassword}
onChange={(e) => { setNewPassword(e.target.value); setFormError('') }}
/>
<button type="button" className="set-reveal-btn" onClick={() => setVisible((v) => !v)}>
{visible ? 'Hide' : 'Show'}
</button>
</div>
</div>
<div className="set-field">
<label className="label" htmlFor="set-confirm">Confirm new password</label>
<input
id="set-confirm"
className="set-input"
type={visible ? 'text' : 'password'}
autoComplete="new-password"
placeholder="Repeat new password"
value={confirmation}
onChange={(e) => { setConfirmation(e.target.value); setFormError('') }}
/>
</div>
</div>
{newPassword && (
<div className="set-rules" aria-live="polite">
<span data-met={longEnough}><Icon name="check" size={13} /> 10 characters or more</span>
<span data-met={withinLimit}><Icon name="check" size={13} /> 72 bytes or fewer</span>
<span data-met={matches && !!confirmation}><Icon name="check" size={13} /> Passwords match</span>
</div>
)}
<div className="set-field">
<label className="label" htmlFor="set-current">Current password</label>
<input
id="set-current"
className="set-input"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => { setCurrentPassword(e.target.value); setFormError('') }}
/>
</div>
{(formError || mutation.error) && (
<p className="set-error" role="alert">
<Icon name="alert" size={15} /> {formError || message(mutation.error)}
</p>
)}
<div className="set-actions">
<span className="set-note">You sign in again after saving.</span>
<button className="btn" data-variant="primary" type="submit" disabled={mutation.isPending || !changed}>
{mutation.isPending ? 'Saving' : 'Save credentials'}
</button>
</div>
</form>
</Panel>
<Panel title="This account" trailing={<Chip tone="accent">operator</Chip>}>
<div className="panel-body set-facts">
<div><span className="label">Username</span><span>{account.username}</span></div>
<div><span className="label">Created</span><M>{stamp(account.created_at)}</M></div>
<div><span className="label">Credentials updated</span><M>{stamp(account.updated_at)}</M></div>
<div><span className="label">Credential store</span><M>bcrypt · orchestra data volume</M></div>
<button className="btn set-signout" type="button" onClick={onLogout}>Sign out</button>
</div>
</Panel>
</div>
)
}
+293
View File
@@ -0,0 +1,293 @@
/* Task detail. Only what orchestra.css does not already carry: the two-column
split, the agent-claim treatment, and the muted superseded register. */
.td-top {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.td-top .page-head {
flex: 1;
min-width: 0;
}
.td-actions {
display: flex;
align-items: center;
gap: 10px;
}
.td-ids {
display: flex;
align-items: baseline;
gap: 8px 18px;
flex-wrap: wrap;
margin-top: -12px;
}
.td-columns {
display: grid;
grid-template-columns: minmax(280px, 5fr) minmax(320px, 7fr);
gap: 20px;
align-items: start;
}
.td-doing > .label {
display: block;
margin: 18px 0 6px;
}
.td-doing > .label:first-child {
margin-top: 0;
}
.td-prose {
margin: 0;
color: var(--text-hi);
}
.td-muted {
color: var(--text-lo);
font-size: 13px;
margin: 6px 0 0;
}
.td-list {
margin: 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 5px;
color: var(--text-mid);
}
.td-more > summary {
cursor: pointer;
color: var(--text-lo);
font-size: 13px;
margin-top: 8px;
list-style: none;
}
.td-more > summary::before {
content: '▸ ';
color: var(--text-lo);
}
.td-more[open] > summary::before {
content: '▾ ';
}
.td-more[open] > summary {
margin-bottom: 8px;
}
/* Operator authority: the one class of content that carries the accent. */
.td-decisions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.td-decisions li {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
padding: 8px 10px;
border-radius: var(--r-sm);
background: var(--accent-dim);
border-left: 2px solid var(--accent);
}
.td-kind {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-hi);
}
.td-subject {
color: var(--text-mid);
}
.td-value {
color: var(--text-hi);
flex: 1;
min-width: 0;
}
/* Superseded: still readable, never dominant, and stripped of the accent. */
.td-superseded .td-decisions li {
background: var(--bg-2);
border-left-color: var(--line-hi);
opacity: 0.7;
}
.td-superseded .td-kind {
color: var(--text-lo);
}
.td-superseded .td-value {
color: var(--text-mid);
text-decoration: line-through;
}
.td-exec {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.td-exec .stat {
gap: 5px;
}
.td-exec .stat-value {
font-size: 20px;
}
/* Agent claims read as quotation, not as state: dashed rule, machine text,
and a tag that names the source. */
.td-claim-head {
display: flex;
align-items: baseline;
gap: 10px;
margin: 18px 0 6px;
}
.td-claim-tag {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0;
text-transform: none;
color: var(--text-lo);
}
.td-claim {
display: flex;
align-items: baseline;
gap: 12px;
margin: 0;
padding: 10px 12px;
background: var(--bg-2);
border: 1px dashed var(--line-hi);
border-radius: var(--r-sm);
}
.td-claim > .mono:first-child {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.td-claim-at {
flex: none;
}
.td-approval {
margin-top: 16px;
padding: 12px;
border: 1px solid var(--accent-line);
background: var(--accent-dim);
border-radius: var(--r-sm);
}
.td-approval .td-prose {
margin: 4px 0 8px;
}
.td-approval-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
.td-approval-row .mono {
flex: 1;
min-width: 0;
font-size: 12px;
}
.td-links {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 16px 0 4px;
}
.td-flow {
display: flex;
flex-direction: column;
gap: 14px;
}
.td-flow .phases {
overflow-x: auto;
padding-bottom: 4px;
}
.td-entries {
display: flex;
gap: 8px 20px;
flex-wrap: wrap;
color: var(--text-lo);
font-size: 13px;
}
.td-entry-phase {
color: var(--text-mid);
}
/* Actions menu: a native disclosure, not a popover library. */
.td-menu {
position: relative;
}
.td-menu > summary {
list-style: none;
cursor: pointer;
}
.td-menu > summary::-webkit-details-marker {
display: none;
}
.td-menu-body {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 10;
min-width: 250px;
background: var(--bg-2);
border: 1px solid var(--line-hi);
border-radius: var(--r-md);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.td-menu-body .row {
gap: 8px;
}
.td-menu-body .row:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.td-menu-body .row:disabled:hover {
background: none;
}
@media (max-width: 900px) {
.td-columns {
grid-template-columns: 1fr;
}
}
+454
View File
@@ -0,0 +1,454 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useParams } from 'react-router-dom'
import { api } from '../api/client'
import type { Action, Detail, Event, Task } from '../api/types'
import { Chip, EndpointGap, M, Panel, PhasePath } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './TaskDetail.css'
/** work_phase is on the wire (domain.Task) but not yet in api/types.ts, which
* another screen owns. Read it through a local widening rather than editing a
* shared file out from under someone. */
type Phased = Task & { work_phase?: string; last_lease_epoch?: string }
const time = (iso?: string) => (iso ? new Date(iso).toISOString().slice(11, 19) : '—')
function ago(iso?: string) {
if (!iso) return '—'
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000))
if (s < 60) return `${s}s ago`
if (s < 3600) return `${Math.floor(s / 60)}m ago`
return `${Math.floor(s / 3600)}h ago`
}
/** ponytail: recomputed on each 5s poll rather than ticking per second. The
* operator reads a lease in minutes; a second-accurate countdown would need a
* timer for no decision it changes. */
function remaining(iso?: string) {
if (!iso) return undefined
const s = Math.round((Date.parse(iso) - Date.now()) / 1000)
if (s <= 0) return 'expired'
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
type Decision = {
id: string
kind: string
subject: string
value: string
at: string
standing: boolean
}
/** The standing authority for this task, reduced from its own log. Mirrors
* domain.ReduceIntent: a decision is retired only by being named, never by a
* newer decision on the same subject. Superseded records are kept so the
* screen can show them muted instead of pretending they never existed. */
function decisionsOf(events: Event[]): Decision[] {
const out: Decision[] = []
const retired = new Set<string>()
for (const e of events) {
const p = (e.payload ?? {}) as {
decision_id?: string
kind?: string
subject?: string
value?: string
supersedes?: string[]
}
if (!p.decision_id) continue
if (e.type === 'HumanDecisionRecorded') {
out.push({
id: p.decision_id,
kind: p.kind ?? 'decision',
subject: p.subject ?? '',
value: p.value ?? '',
at: e.at,
standing: true,
})
p.supersedes?.forEach((id) => retired.add(id))
} else if (e.type === 'HumanDecisionSuperseded') {
retired.add(p.decision_id)
}
}
return out.map((d) => ({ ...d, standing: !retired.has(d.id) }))
}
/** When each phase was entered, from the events Orchestra itself appended.
* Verified state, not a claim. */
function phaseEntries(events: Event[]) {
return events
.filter((e) => e.type === 'WorkPhaseChanged')
.map((e) => ({ phase: (e.payload as { phase?: string })?.phase ?? '', at: e.at }))
.filter((p) => p.phase)
}
/** The last thing the pane actually showed. Agent-authored text: it is
* rendered as a claim, never as orchestra state. */
function lastLine(text?: string) {
const lines = (text ?? '').split('\n').map((l) => l.trimEnd()).filter((l) => l.trim())
return lines[lines.length - 1]
}
function stateTone(task: Task) {
if (task.state === 'blocked' || task.state === 'failed' || task.state === 'needs_attention') {
return 'fault' as const
}
if (task.state === 'completed') return 'done' as const
if (task.state === 'leased') return 'accent' as const
return undefined
}
export function TaskDetail() {
const { id = '' } = useParams()
const client = useQueryClient()
const query = useQuery({
queryKey: ['task', id],
queryFn: () => api.detail(id),
refetchInterval: 5000,
})
const act = useMutation({
mutationFn: (action: string) => api.action(id, action),
onSuccess: () => client.invalidateQueries({ queryKey: ['task', id] }),
})
if (query.isError) {
return (
<main className="page">
<div className="page-head">
<h1>Task</h1>
</div>
<Panel>
<div className="panel-body">
<p className="gap-note">{String((query.error as Error).message)}</p>
</div>
</Panel>
</main>
)
}
if (!query.data) {
return (
<main className="page">
<p className="mono">reading /v1/ui/tasks/{id}</p>
</main>
)
}
const detail: Detail = query.data
const task = detail.task as Phased
const session = detail.session
const capture = session?.capture
const approval = session?.pending_approval
const decisions = decisionsOf(detail.events)
const standing = decisions.filter((d) => d.standing)
const constraints = standing.filter((d) => d.kind === 'constraint')
const effective = standing.filter((d) => d.kind !== 'constraint')
const superseded = decisions.filter((d) => !d.standing)
const acceptance = task.acceptance ?? []
const shown = acceptance.slice(0, 4)
const lease = session?.lease_until ?? task.lease?.until
const activity = lastLine(capture?.text)
const entries = phaseEntries(detail.events)
return (
<main className="page">
{/* ── top area ──────────────────────────────────────────────────── */}
<header className="td-top">
<div className="page-head">
<h1>{task.title || task.external_id || 'untitled task'}</h1>
<Chip tone="accent">{task.work_phase || 'frame'} phase</Chip>
<Chip tone={stateTone(task)}>{task.state}</Chip>
</div>
<div className="td-actions">
<ActionsMenu actions={detail.actions} pending={act.isPending} run={act.mutate} />
<button className="btn" data-variant="primary" disabled title="no endpoint yet">
<Icon name="decision" size={16} />
Steer / Correct
</button>
</div>
</header>
<div className="td-ids">
<span className="label">task id</span>
<M>{task.id}</M>
<span className="label">epoch</span>
<M>{task.lease?.epoch || task.last_lease_epoch || '—'}</M>
<span className="label">project</span>
<M>{task.project}</M>
<span className="label">version</span>
<M>{task.version}</M>
</div>
{act.isError && <p className="gap-note">{String((act.error as Error).message)}</p>}
<div className="td-columns">
{/* ── what we're doing ────────────────────────────────────────── */}
<Panel title="What we're doing">
<div className="panel-body td-doing">
<h3 className="label">goal</h3>
<p className="td-prose">{task.description || task.title || 'No goal recorded.'}</p>
<h3 className="label">
acceptance <M>{acceptance.length}</M>
</h3>
{acceptance.length === 0 ? (
<p className="td-muted">No acceptance criteria on the contract.</p>
) : (
<>
<ul className="td-list">
{shown.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
{acceptance.length > shown.length && (
<details className="td-more">
<summary>
<M>+{acceptance.length - shown.length}</M> more criteria
</summary>
<ul className="td-list">
{acceptance.slice(shown.length).map((c) => (
<li key={c}>{c}</li>
))}
</ul>
</details>
)}
</>
)}
{task.quality_gate && (
<p className="td-muted">
<span className="label">quality gate</span> <M>{task.quality_gate}</M>
</p>
)}
{effective.length > 0 && (
<>
<h3 className="label">effective human decisions</h3>
<ul className="td-decisions">
{effective.map((d) => (
<li key={d.id}>
<span className="td-kind">{d.kind}</span>
<span className="td-subject">{d.subject}</span>
<span className="td-value">{d.value}</span>
<M>{time(d.at)}</M>
</li>
))}
</ul>
</>
)}
{constraints.length > 0 && (
<>
<h3 className="label">active constraints</h3>
<ul className="td-decisions">
{constraints.map((d) => (
<li key={d.id}>
<span className="td-kind">constraint</span>
<span className="td-subject">{d.subject}</span>
<span className="td-value">{d.value}</span>
<M>{time(d.at)}</M>
</li>
))}
</ul>
</>
)}
{superseded.length > 0 && (
<details className="td-more td-superseded">
<summary>
<M>{superseded.length}</M> superseded {superseded.length === 1 ? 'decision' : 'decisions'}
</summary>
<ul className="td-decisions">
{superseded.map((d) => (
<li key={d.id}>
<span className="td-kind">{d.kind}</span>
<span className="td-subject">{d.subject}</span>
<span className="td-value">{d.value}</span>
<M>{time(d.at)}</M>
</li>
))}
</ul>
</details>
)}
</div>
</Panel>
{/* ── live execution ──────────────────────────────────────────── */}
<Panel
title="Live execution"
trailing={
session?.agent_status ? (
<Chip tone={session.agent_status === 'blocked' ? 'fault' : 'accent'}>
{session.agent_status}
</Chip>
) : (
<span className="mono">no session</span>
)
}
>
<div className="panel-body">
<div className="td-exec">
<div className="stat">
<span className="label">worker / harness</span>
<M>{session?.harness_id || task.lease?.harness_id || task.last_harness_id || '—'}</M>
</div>
<div className="stat">
<span className="label">pane</span>
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
<span className="td-muted">
<M>{task.pane_state || 'unknown'}</M>
</span>
</div>
<div className="stat">
<span className="label">lease remaining</span>
<span className="stat-value">{remaining(lease) ?? '—'}</span>
<span className="td-muted">
until <M>{time(lease)}</M>
</span>
</div>
<div className="stat">
<span className="label">last progress</span>
<span className="stat-value">{ago(capture?.at)}</span>
<span className="td-muted">
capture rev <M>{capture?.revision ?? '—'}</M>
</span>
</div>
</div>
{/* Context occupancy is the one number this panel is supposed to
carry and the read model does not produce it. Say so rather
than compute a plausible-looking figure. */}
<EndpointGap
path="/v1/ui/tasks/:id (no context accounting on session)"
what="Context occupancy — tokens used against the harness window — has no field in the read model, so no meter is drawn."
/>
<h3 className="label td-claim-head">
current activity
<span className="td-claim-tag">
agent-supplied · pane capture{capture?.source ? ` · ${capture.source}` : ''}
</span>
</h3>
{activity ? (
<p className="td-claim">
<M>{activity}</M>
<span className="td-claim-at">
<M>{time(capture?.at)}</M>
</span>
</p>
) : (
<p className="td-muted">No capture from the owning worker.</p>
)}
{approval && (
<div className="td-approval">
<span className="label">pending approval</span>
<p className="td-prose">{approval.summary}</p>
{approval.command && <M>{approval.command}</M>}
<div className="td-approval-row">
<span className="mono">
{approval.pane_id} · rev {approval.capture_revision}
</span>
<button
className="btn"
data-variant="primary"
disabled={act.isPending}
onClick={() => act.mutate('grant_approval')}
>
Grant
</button>
<button className="btn" disabled={act.isPending} onClick={() => act.mutate('deny_approval')}>
Deny
</button>
</div>
</div>
)}
<div className="td-links">
<Link className="btn" to={`/tasks/${task.id}/terminal`}>
<Icon name="terminal" size={16} />
Open live pane
</Link>
{task.handoff_ref && (
<a className="btn" href={`/v1/ui/artifacts/${task.handoff_ref}`}>
<Icon name="file" size={16} />
Handoff
</a>
)}
{detail.report_ref && (
<a className="btn" href={`/v1/ui/artifacts/${detail.report_ref}`}>
<Icon name="file" size={16} />
Report
</a>
)}
</div>
<EndpointGap
path="/v1/ui/tasks/:id/{logs,launch_context,diff}"
what="Worker logs, launch context and git diff have no endpoint, so those links are not offered."
/>
</div>
</Panel>
</div>
{/* ── workflow ──────────────────────────────────────────────────── */}
<Panel title="Workflow" trailing={<span className="mono">ace-fca</span>}>
<div className="panel-body td-flow">
<PhasePath current={task.work_phase} />
{entries.length > 0 ? (
<div className="td-entries">
{entries.map((e) => (
<span key={`${e.phase}-${e.at}`}>
<span className="td-entry-phase">{e.phase}</span> entered <M>{time(e.at)}</M>
</span>
))}
</div>
) : (
<p className="td-muted">
No WorkPhaseChanged event in this task's log the path shows the current phase only.
</p>
)}
</div>
</Panel>
</main>
)
}
/** Operator actions, exactly as the server declares them. An action that
* needs input the console cannot collect stays disabled and says what it
* wants offering a button that will 409 is the dishonest option. */
function ActionsMenu({
actions,
pending,
run,
}: {
actions: Action[]
pending: boolean
run: (action: string) => void
}) {
return (
<details className="td-menu">
<summary className="btn">
Actions <Icon name="chevron-right" size={16} />
</summary>
<div className="td-menu-body">
{actions.map((a) => {
const blocked = !a.enabled || (a.needs?.length ?? 0) > 0
return (
<button
key={a.id}
className="row"
disabled={blocked || pending}
onClick={() => run(a.id)}
title={a.needs?.length ? `needs ${a.needs.join(', ')}` : a.reason}
>
<span className="row-main">
<span className="row-title">{a.id}</span>
<span className="row-sub">
{a.needs?.length ? `needs ${a.needs.join(', ')}` : a.enabled ? a.reason || '' : a.reason || 'unavailable'}
</span>
</span>
</button>
)
})}
</div>
</details>
)
}
+202
View File
@@ -0,0 +1,202 @@
/* Tasks list. Only what the shared shell does not already carry: the row
column geometry, the group disclosure, and the filter bar. */
.tasks-controls {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.tasks-tabs {
display: flex;
gap: 2px;
flex-wrap: wrap;
}
.tasks-tab {
background: none;
border: 0;
border-bottom: 2px solid transparent;
padding: 7px 11px;
color: var(--text-mid);
cursor: pointer;
transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.tasks-tab span {
font-size: 12px;
margin-left: 5px;
}
.tasks-tab:hover {
color: var(--text-hi);
}
.tasks-tab[aria-selected='true'] {
color: var(--text-hi);
border-bottom-color: var(--accent);
}
.tasks-filters {
display: flex;
gap: 10px;
margin-left: auto;
flex-wrap: wrap;
}
.tasks-filter {
display: flex;
align-items: center;
gap: 7px;
}
.tasks-filter select {
font-family: var(--mono);
font-size: 12px;
color: var(--text-machine);
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 5px 8px;
cursor: pointer;
}
.tasks-filter select:hover {
border-color: var(--line-hi);
}
.page-head .count {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-2);
border: 1px solid var(--line);
}
/* ── group disclosure ────────────────────────────────────────────────── */
.tasks-group > summary {
cursor: pointer;
list-style: none;
}
.tasks-group > summary::-webkit-details-marker {
display: none;
}
.tasks-group:not([open]) > summary {
border-bottom: 0;
}
.tasks-group > summary > svg:first-child {
color: var(--text-lo);
}
.tasks-group > summary > svg:last-child {
margin-left: auto;
color: var(--text-lo);
transform: rotate(90deg);
transition: transform var(--fast) var(--ease);
}
.tasks-group[open] > summary > svg:last-child {
transform: rotate(-90deg);
}
/* ── row geometry ────────────────────────────────────────────────────── */
.task-row {
display: flex;
align-items: center;
gap: 18px;
color: inherit;
}
.task-row > svg:last-child {
color: var(--text-lo);
flex: none;
}
.task-row-main {
flex: 1 1 220px;
min-width: 0;
display: flex;
flex-direction: column;
}
.task-row-main .row-title,
.task-row-main .row-sub {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-row-phase {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.task-row-phase-name {
font-size: 10px;
letter-spacing: 0.04em;
}
/* The compact path carries no labels, so the connector no longer needs to
sit above a name. */
.tasks-phases .phase-step {
min-width: 0;
}
.tasks-phases .phase-link {
width: 14px;
margin-bottom: 0;
}
.task-row-worker {
flex: none;
width: 150px;
display: flex;
flex-direction: column;
font-size: 13px;
}
.task-row-when {
flex: none;
width: 118px;
display: flex;
flex-direction: column;
font-size: 12px;
}
.task-row-status {
flex: none;
display: flex;
gap: 6px;
justify-content: flex-end;
min-width: 130px;
}
@media (max-width: 900px) {
.task-row-phase,
.task-row-when {
display: none;
}
.task-row-worker {
width: 110px;
}
}
@media (max-width: 640px) {
.task-row-worker {
display: none;
}
.tasks-filters {
margin-left: 0;
}
}
+329
View File
@@ -0,0 +1,329 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Overview, Task } from '../api/types'
import { Chip, Empty, EndpointGap, M, PHASES, PhasePath } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Tasks.css'
type GroupKey = 'attention' | 'running' | 'waiting' | 'completed' | 'failed'
const GROUPS: { key: GroupKey; title: string; icon: string; open: boolean }[] = [
{ key: 'attention', title: 'Needs attention', icon: 'alert', open: true },
{ key: 'running', title: 'Running', icon: 'play', open: true },
{ key: 'waiting', title: 'Waiting / queued', icon: 'queue', open: true },
{ key: 'completed', title: 'Completed', icon: 'check', open: false },
{ key: 'failed', title: 'Failed', icon: 'x', open: false },
]
function groupOf(task: Task): GroupKey {
switch (task.state) {
case 'needs_attention':
case 'blocked':
return 'attention'
case 'leased':
return 'running'
case 'completed':
return 'completed'
case 'failed':
return 'failed'
default:
return 'waiting'
}
}
/** The harness holding the lease, or the last one that did. Nothing is
* invented: a task that never ran reports no worker. */
function harnessOf(task: Task) {
return task.lease?.harness_id || task.last_harness_id || ''
}
function stateTone(task: Task) {
if (task.state === 'failed' || task.state === 'blocked') return 'fault' as const
if (task.state === 'needs_attention') return 'warn' as const
if (task.state === 'completed') return 'done' as const
if (task.state === 'leased') return 'accent' as const
return undefined
}
function ago(at?: string) {
if (!at) return ''
const ms = Date.now() - Date.parse(at)
if (!Number.isFinite(ms)) return ''
return `${span(ms)} ago`
}
function span(ms: number) {
const s = Math.max(0, Math.round(ms / 1000))
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.round(s / 60)}m`
if (s < 86400) return `${Math.round(s / 3600)}h`
return `${Math.round(s / 86400)}d`
}
/** The one time value a task actually carries per state. There is no
* per-task updated_at on the overview, so this never claims one. */
function timing(task: Task): { label: string; value: string } | undefined {
if (task.lease?.until) {
const left = Date.parse(task.lease.until) - Date.now()
return left > 0
? { label: 'lease', value: `${span(left)} left` }
: { label: 'lease', value: `expired ${span(-left)} ago` }
}
if (task.blocked_at) return { label: 'blocked', value: ago(task.blocked_at) }
if (task.next_retry_at) {
const left = Date.parse(task.next_retry_at) - Date.now()
return { label: 'retry', value: left > 0 ? `in ${span(left)}` : 'due' }
}
if (task.due) return { label: 'due', value: task.due.slice(0, 10) }
return undefined
}
function unique(values: (string | undefined)[]) {
return [...new Set(values.filter((value): value is string => !!value))].sort()
}
function Select({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: string[]
onChange: (next: string) => void
}) {
return (
<label className="tasks-filter">
<span className="label">{label}</span>
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">any</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
)
}
function Row({ task, agentStatus }: { task: Task; agentStatus?: string }) {
const harness = harnessOf(task)
const when = timing(task)
return (
<Link className="row task-row" to={`/tasks/${task.id}`}>
<span className="task-row-main">
<span className="row-title">{task.title || task.external_id || 'untitled task'}</span>
<span className="row-sub">
{task.project} · <M>{task.external_id || task.id}</M>
</span>
</span>
<span className="task-row-phase tasks-phases">
<PhasePath current={task.lifecycle_phase} labels={false} />
<span className="task-row-phase-name mono">{task.lifecycle_phase || '—'}</span>
</span>
<span className="task-row-worker">
{harness ? (
<>
<M>{harness}</M>
{task.last_pane_id && <span className="row-sub mono">{task.last_pane_id}</span>}
</>
) : (
<span className="row-sub">unassigned</span>
)}
</span>
<span className="task-row-when">
{when && (
<>
<span className="label">{when.label}</span>
<M>{when.value}</M>
</>
)}
</span>
<span className="task-row-status">
{agentStatus && <Chip>{agentStatus}</Chip>}
<Chip tone={stateTone(task)}>{task.block_reason || task.state}</Chip>
</span>
<Icon name="chevron-right" size={16} />
</Link>
)
}
function Group({
title,
icon,
open,
tasks,
statusFor,
}: {
title: string
icon: string
open: boolean
tasks: Task[]
statusFor: (task: Task) => string | undefined
}) {
return (
<details className="panel tasks-group" open={open && tasks.length > 0}>
<summary className="panel-head">
<Icon name={icon} size={17} />
<h2>{title}</h2>
<span className="count">{tasks.length}</span>
<Icon name="chevron-right" size={16} />
</summary>
{tasks.length ? (
<div className="rows">
{tasks.map((task) => (
<Row key={task.id} task={task} agentStatus={statusFor(task)} />
))}
</div>
) : (
<Empty title="Nothing here" />
)}
</details>
)
}
export function Tasks() {
const { data, isLoading, error } = useQuery<Overview>({
queryKey: ['overview'],
queryFn: api.overview,
refetchInterval: 5000,
})
const [project, setProject] = useState('')
const [phase, setPhase] = useState('')
const [worker, setWorker] = useState('')
const [attention, setAttention] = useState('')
const [group, setGroup] = useState<GroupKey | ''>('')
const tasks = useMemo(() => data?.tasks ?? [], [data])
/** Agent status comes from the live session, matched by pane. It is an
* agent claim, so it stays a separate chip from orchestra's own state. */
const statusFor = useMemo(() => {
const byPane = new Map((data?.sessions ?? []).map((s) => [s.pane_id, s]))
return (task: Task) =>
task.state === 'leased' ? byPane.get(task.last_pane_id)?.agent_status : undefined
}, [data])
const filtered = tasks.filter((task) => {
if (project && task.project !== project) return false
if (phase && task.lifecycle_phase !== phase) return false
if (worker && harnessOf(task) !== worker) return false
if (attention === 'needs' && groupOf(task) !== 'attention') return false
if (attention === 'clear' && groupOf(task) === 'attention') return false
if (attention && attention !== 'needs' && attention !== 'clear' && task.block_reason !== attention)
return false
return true
})
const counts = (key: GroupKey) => filtered.filter((task) => groupOf(task) === key).length
const shown = GROUPS.filter((g) => !group || g.key === group)
return (
<main className="page">
<div className="page-head">
<h1>Tasks</h1>
<p>All work under orchestration.</p>
<span className="count mono">{tasks.length}</span>
</div>
<div className="tasks-controls">
<div className="tasks-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={group === ''}
className="tasks-tab"
onClick={() => setGroup('')}
>
All <span className="mono">{filtered.length}</span>
</button>
{GROUPS.map((g) => (
<button
key={g.key}
type="button"
role="tab"
aria-selected={group === g.key}
className="tasks-tab"
onClick={() => setGroup(group === g.key ? '' : g.key)}
>
{g.title} <span className="mono">{counts(g.key)}</span>
</button>
))}
</div>
<div className="tasks-filters">
<Select
label="Project"
value={project}
options={unique(tasks.map((task) => task.project))}
onChange={setProject}
/>
<Select label="Phase" value={phase} options={[...PHASES]} onChange={setPhase} />
<Select
label="Worker"
value={worker}
options={unique(tasks.map(harnessOf))}
onChange={setWorker}
/>
<Select
label="Attention"
value={attention}
options={['needs', 'clear', ...unique(tasks.map((task) => task.block_reason))]}
onChange={setAttention}
/>
</div>
</div>
{error && (
<div className="panel panel-body">
<p className="gap-note">/v1/ui/overview failed: {(error as Error).message}</p>
</div>
)}
{isLoading && !data && (
<div className="panel panel-body">
<p className="gap-note">loading /v1/ui/overview</p>
</div>
)}
{data && !tasks.length && (
<div className="panel">
<Empty title="No tasks yet">
<p>Orchestra has no work on record. Ingest a task to start.</p>
</Empty>
</div>
)}
{data &&
tasks.length > 0 &&
shown.map((g) => (
<Group
key={g.key}
title={g.title}
icon={g.icon}
open={g.open || !!group}
tasks={filtered.filter((task) => groupOf(task) === g.key)}
statusFor={statusFor}
/>
))}
{data && tasks.length > 0 && (
<div className="panel">
<EndpointGap
path="/v1/ui/overview carries no per-task progress, ETA or context occupancy"
what="Rows show lease, block and retry times because those are the only per-task clocks orchestra records. Progress bars and context meters are left out rather than estimated."
/>
</div>
)}
</main>
)
}
+192
View File
@@ -0,0 +1,192 @@
/* The live pane. Tokens only — nothing here redefines the ethos ramp. */
.term {
min-height: 0;
}
.term-head {
align-items: center;
}
.term-identity {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.term-sep {
color: var(--text-lo);
}
.term-controls {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.term-mode {
gap: 6px;
}
.term-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
/* The frame. Depth from a hairline and a surface step, never from glass. */
.term-frame {
background: var(--bg-0);
border: 1px solid var(--line-hi);
border-radius: var(--r-md);
overflow: hidden;
min-width: 0;
}
.term-frame[data-lost='true'] {
border-color: rgba(224, 108, 96, 0.42);
}
.term-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--bg-2);
border-bottom: 1px solid var(--line);
font-size: 11px;
}
.term-bar-right {
margin-left: auto;
display: flex;
gap: 14px;
}
/* Exact pane characters: no wrapping, no reflow, no prettifying. */
.term-text {
margin: 0;
padding: 14px 16px;
height: min(62vh, 720px);
overflow: auto;
white-space: pre;
tab-size: 8;
font-size: 13px;
line-height: 1.35;
color: #cfc6b4;
background: var(--bg-0);
}
.term-text:focus-visible {
outline-offset: -2px;
}
.term-foot {
display: flex;
align-items: center;
gap: 12px;
min-height: 0;
padding: 0 12px;
}
.term-foot:not(:empty) {
padding: 8px 12px;
border-top: 1px solid var(--line);
background: var(--bg-1);
}
.term-jump {
height: 28px;
font-size: 12px;
}
.term-warn {
font-size: 12px;
color: #e06c60;
}
.term-side {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.term-fields,
.term-lost {
display: flex;
flex-direction: column;
gap: 10px;
}
.term-lost p {
margin: 0 0 2px;
color: var(--text-mid);
}
.term-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.term-field-value {
font-size: 12px;
color: var(--text-mid);
overflow-wrap: anywhere;
}
.term-health {
display: inline-flex;
align-items: center;
gap: 7px;
}
.term-link:hover {
color: var(--accent-hi);
}
.term-resubmit p {
margin: 8px 0 0;
font-size: 12px;
color: var(--text-mid);
}
.term-events {
display: flex;
flex-direction: column;
gap: 7px;
}
.term-event {
display: flex;
gap: 10px;
font-size: 12px;
color: var(--text-mid);
}
.term-muted {
margin: 0;
color: var(--text-lo);
}
@media (max-width: 1040px) {
.term-layout {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.term-controls {
margin-left: 0;
width: 100%;
}
.term-text {
height: 54vh;
font-size: 12px;
}
}
+320
View File
@@ -0,0 +1,320 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Capture, Detail } from '../api/types'
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Terminal.css'
/** This is the screen that must not lie. Everything inside the frame is the
* pane's own bytes; everything outside it is state Orchestra can prove. */
function ago(at?: string) {
if (!at) return '—'
const seconds = Math.max(0, Math.round((Date.now() - new Date(at).getTime()) / 1000))
if (seconds < 60) return `${seconds}s ago`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
return `${Math.floor(seconds / 3600)}h ago`
}
function clock(at?: string) {
return at ? new Date(at).toISOString().slice(11, 19) : '—'
}
function until(at?: string) {
if (!at) return '—'
const seconds = Math.round((new Date(at).getTime() - Date.now()) / 1000)
if (seconds <= 0) return 'expired'
const m = Math.floor(seconds / 60)
return `${String(m).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="term-field">
<span className="label">{label}</span>
<span className="term-field-value">{value}</span>
</div>
)
}
export function Terminal() {
const { id = '' } = useParams()
const queries = useQueryClient()
const detail = useQuery<Detail>({
queryKey: ['detail', id],
queryFn: () => api.detail(id),
refetchInterval: 3000,
})
// The last frame we actually received, kept across refreshes so a lost pane
// still shows what was true when it was lost instead of an empty box.
const [frame, setFrame] = useState<Capture | undefined>()
const [lostAt, setLostAt] = useState<string | undefined>()
const [copied, setCopied] = useState(false)
const [pinned, setPinned] = useState(true)
const view = useRef<HTMLPreElement>(null)
const task = detail.data?.task
const session = detail.data?.session
const capture = session?.capture
useEffect(() => {
if (capture) {
setFrame(capture)
setLostAt(undefined)
return
}
// A leased task with no capture is a lost pane, not an empty one.
if (detail.isSuccess && task?.state === 'leased') {
setLostAt((was) => was ?? new Date().toISOString())
}
}, [capture, detail.isSuccess, task?.state])
// Scrollback inspection must not disturb the live view: we only follow the
// tail while the operator is already sitting at the tail.
useEffect(() => {
const el = view.current
if (el && pinned) el.scrollTop = el.scrollHeight
}, [frame?.revision, pinned])
const resubmit = useMutation({
mutationFn: () => api.action(id, 'resubmit'),
onSuccess: (next) => queries.setQueryData(['detail', id], next),
})
const canResubmit = detail.data?.actions.find((a) => a.id === 'resubmit')?.enabled ?? false
const lost = Boolean(lostAt) && Boolean(frame)
const status = session?.agent_status || task?.pane_state || 'unknown'
const health = lost ? 'error' : status === 'blocked' ? 'degraded' : status ? 'healthy' : 'unknown'
if (detail.isLoading) {
return (
<main className="page">
<div className="page-head">
<h1>Terminal</h1>
</div>
<Panel>
<div className="panel-body">Reading the pane</div>
</Panel>
</main>
)
}
if (detail.isError || !task) {
return (
<main className="page">
<div className="page-head">
<h1>Terminal</h1>
</div>
<Panel>
<Empty title="No such task">
<p>
<M>{id}</M> is not in the store, so there is no pane to show.
</p>
</Empty>
</Panel>
</main>
)
}
return (
<main className="page term">
<div className="page-head term-head">
<h1>Terminal live pane</h1>
{lost ? <Chip tone="fault">Pane lost</Chip> : capture ? <Chip tone="accent">Live</Chip> : <Chip>No session</Chip>}
<span className="term-identity">
<M>{session?.harness_id || task.last_harness_id || 'no worker'}</M>
<span className="term-sep">/</span>
<M>{session?.pane_id || task.last_pane_id || 'no pane'}</M>
</span>
<div className="term-controls">
<span className="chip term-mode" data-mode="read-only">
<Icon name="terminal" size={14} /> Read-only
</span>
<button
type="button"
className="btn"
disabled
title="Orchestra has no keystroke-forwarding endpoint"
>
Take control
</button>
<button
type="button"
className="btn"
disabled={!frame}
onClick={() => {
if (!frame) return
void navigator.clipboard.writeText(frame.text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}}
>
{copied ? 'Copied' : 'Copy frame'}
</button>
</div>
</div>
<div className="term-layout">
<div className="term-frame" data-lost={lost}>
<div className="term-bar">
<M>{session?.pane_id || task.last_pane_id || '—'}</M>
<span className="term-bar-right">
<M>rev {frame ? frame.revision : '—'}</M>
<M>{frame?.source || '—'}</M>
<M>{clock(frame?.at)}</M>
</span>
</div>
{frame ? (
<pre
ref={view}
className="term-text"
tabIndex={0}
aria-label="Pane capture, read-only"
onScroll={(event) => {
const el = event.currentTarget
setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 24)
}}
>
{frame.text}
</pre>
) : (
<Empty title="No capture">
<p>
This task holds no leased session, so no worker is publishing pane text for{' '}
<M>{task.id}</M>.
</p>
</Empty>
)}
<div className="term-foot">
{frame?.truncated && <span className="term-warn">frame truncated by the worker</span>}
{!pinned && (
<button
type="button"
className="btn term-jump"
onClick={() => {
setPinned(true)
const el = view.current
if (el) el.scrollTop = el.scrollHeight
}}
>
<Icon name="chevron-right" size={14} /> Scrollback held jump to live
</button>
)}
</div>
</div>
<div className="term-side">
{lost && (
<Panel title="Pane lost">
<div className="panel-body term-lost">
<p>
The frame above is the last one Orchestra received. It is history, not the
present.
</p>
<Field label="Frame captured" value={<M>{clock(frame?.at)} UTC</M>} />
<Field label="Capture stopped" value={<M>{clock(lostAt)} UTC</M>} />
<Field
label="Lease consequence"
value={
task.lease?.until ? (
<span>
the lease still runs until <M>{clock(task.lease.until)} UTC</M>; nothing is
rerouted before it expires
</span>
) : (
<span>no lease is held, so this task can be routed again immediately</span>
)
}
/>
</div>
</Panel>
)}
<Panel title="Session">
<div className="panel-body term-fields">
<Field label="Task" value={<Link to={`/tasks/${task.id}`} className="term-link"><M>{task.id}</M></Link>} />
<Field label="Worker" value={<M>{session?.harness_id || task.last_harness_id || '—'}</M>} />
<Field label="Pane" value={<M>{session?.pane_id || task.last_pane_id || '—'}</M>} />
<Field label="Lease epoch" value={<M>{task.lease?.epoch || '—'}</M>} />
<Field
label="Lease"
value={
task.lease?.until ? (
<span>
<M>{until(task.lease.until)}</M> left · until <M>{clock(task.lease.until)} UTC</M>
</span>
) : (
<M>none</M>
)
}
/>
<Field label="Capture source" value={<M>{frame?.source || '—'}</M>} />
<Field label="Capture revision" value={<M>{frame ? frame.revision : '—'}</M>} />
<Field label="Last verified progress" value={<M>{ago(frame?.at)}</M>} />
<Field label="Mode" value={<M>read-only</M>} />
</div>
</Panel>
<Panel title="Health">
<div className="panel-body term-fields">
<Field
label="Agent"
value={
<span className="term-health">
<Dot health={health} />
<M>{status}</M>
</span>
}
/>
<Field label="Checked" value={<M>{ago(task.last_session?.checked_at || frame?.at)}</M>} />
{(session?.blocker || task.blocker) && (
<Field label="Blocker" value={<span>{session?.blocker || task.blocker}</span>} />
)}
</div>
</Panel>
<Panel title="Keyboard forwarding">
<EndpointGap
path="POST /v1/ui/tasks/:id/actions/send_keys"
what="Orchestra exposes no endpoint that forwards arbitrary keystrokes to a pane. The only operator input that reaches a live pane today is resubmit (a single Enter on text Orchestra itself submitted) and approval grant/deny. Take control stays disabled rather than opening a text box that goes nowhere."
/>
<div className="panel-body term-resubmit">
<button
type="button"
className="btn"
data-variant="primary"
disabled={!canResubmit || resubmit.isPending}
onClick={() => resubmit.mutate()}
>
{resubmit.isPending ? 'Sending…' : 'Resubmit (send Enter)'}
</button>
<p>
Presses Enter once on input Orchestra placed in this pane. It sends no other key and
changes no lifecycle state.
</p>
{resubmit.isError && (
<p className="term-warn">{(resubmit.error as Error).message}</p>
)}
</div>
</Panel>
<Panel title="Recent events" count={detail.data?.events.length}>
<div className="panel-body term-events">
{(detail.data?.events ?? []).slice(-5).reverse().map((event) => (
<div key={event.id} className="term-event">
<M>{clock(event.at)}</M>
<span>{event.type}</span>
</div>
))}
{!detail.data?.events.length && <p className="term-muted">No events recorded yet.</p>}
</div>
</Panel>
</div>
</div>
</main>
)
}
+198
View File
@@ -0,0 +1,198 @@
/* Workers screen. Only what the shared sheet does not already carry:
the two-column split, the label/value field row, and the observations ring. */
.workers-split {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.workers-detail {
min-width: 0;
}
.workers .worker-row {
cursor: pointer;
}
.workers .worker-row[aria-selected='true'] {
background: var(--bg-2);
box-shadow: inset 3px 0 0 var(--accent);
}
.workers .status-cell {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.workers .worker-id {
font-size: 15px;
color: var(--accent-hi);
}
.workers .dim {
color: var(--text-lo);
}
.workers .task-link {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.workers .task-link .mono {
color: var(--accent-hi);
}
.workers .task-link:hover .mono {
text-decoration: underline;
}
.workers .task-title {
color: var(--text-mid);
font-size: 13px;
}
/* A human label, a machine value, on one line. Law 1 drawn as geometry. */
.workers .field {
display: flex;
gap: 16px;
align-items: baseline;
padding: 5px 0;
}
.workers .field-name {
flex: none;
width: 150px;
color: var(--text-mid);
font-size: 13px;
}
.workers .field-value {
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.workers .chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.workers .steps {
margin: 0 0 12px;
padding-left: 20px;
color: var(--text-mid);
display: flex;
flex-direction: column;
gap: 4px;
}
.workers .reg-lead {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
color: var(--text-hi);
}
.workers .reg-lead svg {
color: var(--accent);
}
/* the observations ring
One distinct failure per row, with its repeat count kept whole. A count
of 41 means something looped 41 times; that number is the finding, so it
gets weight instead of a parenthesis. */
.workers .obs-latest {
margin: 0 0 12px;
border-style: solid;
border-color: var(--line-hi);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.workers .obs {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.workers .obs-item {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 10px 12px;
}
.workers .obs-head {
display: flex;
gap: 10px;
align-items: baseline;
}
.workers .obs-count {
flex: none;
font-size: 15px;
color: var(--text-hi);
min-width: 42px;
text-align: right;
}
/* Repeats are the signal that something is stuck rather than merely broken. */
.workers .obs-count[data-repeat='true'] {
color: var(--accent-hi);
}
.workers .obs-msg {
min-width: 0;
color: var(--text-machine);
font-size: 12.5px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.workers .obs-when {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin-top: 8px;
padding-left: 52px;
font-size: 12px;
}
.workers .obs-when .mono {
color: var(--text-lo);
}
@media (max-width: 1100px) {
.workers-split {
grid-template-columns: 1fr;
}
.workers .obs-when {
padding-left: 0;
}
.workers .field {
flex-direction: column;
gap: 2px;
}
.workers .field-name {
width: auto;
}
}
+362
View File
@@ -0,0 +1,362 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Task, Worker, WorkerHealth } from '../api/types'
import { Chip, Dot, Empty, EndpointGap, M, Panel } from '../components/Primitives'
import type { Health } from '../components/Primitives'
import { Icon } from '../components/Icon'
import './Workers.css'
/** The coordinator's Go structs carry `observations` on WorkerHealth; the
* shared type file has not caught up and is owned elsewhere, so the shape is
* narrowed here rather than guessed at the call site. */
interface Observation {
message: string
count: number
first: string
last: string
}
type Health2 = WorkerHealth & { observations?: Observation[] }
/** Go marshals a zero time.Time as year 1 `omitempty` does not omit a
* struct. Treating that as a real timestamp would print "2025y ago". */
function absent(iso?: string) {
return !iso || iso.startsWith('0001-')
}
function ago(iso?: string) {
if (absent(iso)) return '—'
const s = Math.max(0, (Date.now() - new Date(iso as string).getTime()) / 1000)
if (s < 60) return `${Math.floor(s)}s ago`
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
function clock(iso?: string) {
if (absent(iso)) return '—'
return new Date(iso as string).toISOString().slice(11, 19) + ' UTC'
}
function health(w: Worker): Health {
if (!w.online) return 'unknown'
if (w.health.herdr_status === 'unreachable') return 'error'
if (w.health.herdr_status === 'unknown') return 'unknown'
return w.health.last_error ? 'degraded' : 'healthy'
}
function statusLabel(w: Worker) {
if (!w.online) return 'Offline'
if (w.health.herdr_status === 'unreachable') return 'Backend unreachable'
if (w.health.active_task_id) return 'Busy'
return w.health.last_error ? 'Degraded' : 'Idle'
}
/** The ring is the evidence. A message with a count of 41 means a loop ran 41
* times, and that number is the whole point it is never collapsed away. */
function Observations({ items }: { items: Observation[] }) {
return (
<ul className="obs">
{items.map((o) => (
<li className="obs-item" key={o.message + o.first}>
<div className="obs-head">
<span className="obs-count mono" data-repeat={o.count > 1} title={`${o.count} occurrences`}>
×{o.count}
</span>
<span className="obs-msg mono">{o.message}</span>
</div>
<div className="obs-when">
<span className="label">first</span>
<M>{clock(o.first)}</M>
<span className="label">last</span>
<M>{clock(o.last)}</M>
<M>({ago(o.last)})</M>
</div>
</li>
))}
</ul>
)
}
function Field({ name, children }: { name: string; children: React.ReactNode }) {
return (
<div className="field">
<span className="field-name">{name}</span>
<span className="field-value">{children}</span>
</div>
)
}
function Detail({ worker, tasks }: { worker: Worker; tasks: Task[] }) {
const h = worker.health as Health2
const obs = h.observations ?? []
const task = tasks.find((t) => t.id === h.active_task_id)
return (
<div className="grid">
<Panel>
<header className="panel-head">
<h2 className="mono worker-id">{worker.id}</h2>
<span className="trailing">
<Dot health={health(worker)} /> {statusLabel(worker)}
</span>
</header>
<div className="panel-body">
<Field name="Backend">
<M>{h.backend || 'unknown'}</M>
</Field>
<Field name="Pane backend status">
<Dot health={health(worker)} /> <M>{h.herdr_status}</M>
</Field>
<Field name="Checked">
<M>{clock(h.checked_at)}</M> <span className="dim mono">({ago(h.checked_at)})</span>
</Field>
<Field name="Last seen">
<M>{clock(worker.last_seen)}</M>{' '}
<span className="dim mono">({ago(worker.last_seen)})</span>
</Field>
<Field name="Capacity">
<M>{worker.capacity}</M>
</Field>
{worker.address && (
<Field name="Address">
<M>{worker.address}</M>
</Field>
)}
</div>
</Panel>
<Panel title="Current session">
<div className="panel-body">
{h.active_task_id ? (
<>
<Field name="Task">
<Link className="task-link" to={`/tasks/${h.active_task_id}`}>
<M>{h.active_task_id}</M>
{task?.title && <span className="task-title">{task.title}</span>}
</Link>
</Field>
{task?.lifecycle_phase && (
<Field name="Phase">
<Chip tone="accent">{task.lifecycle_phase}</Chip>
</Field>
)}
<Field name="Pane">
<M>{h.active_pane_id || '—'}</M>
</Field>
</>
) : (
<p className="dim">No task leased to this worker.</p>
)}
</div>
</Panel>
<Panel title="Build">
<div className="panel-body">
<Field name="Revision">
<M>{worker.build?.revision || 'unknown'}</M>
</Field>
<Field name="Built">
<M>{worker.build?.time || 'unknown'}</M>
</Field>
{worker.build?.dirty === 'true' && (
<Field name="Tree">
<Chip tone="warn">dirty built from uncommitted changes</Chip>
</Field>
)}
</div>
</Panel>
<Panel title="Declared projects" trailing={<span className="dim">reported by worker</span>}>
<div className="panel-body">
{worker.supported_projects?.length ? (
<div className="chips">
{worker.supported_projects.map((p) => (
<Chip key={p}>
<span className="mono">{p}</span>
</Chip>
))}
</div>
) : (
<p className="dim">The worker declared no projects, so nothing routes to it.</p>
)}
<EndpointGap
path="PATCH /v1/federation/workers/:id"
what="Projects and capabilities are set by the worker at registration. There is no endpoint to edit them from here, and a form that only looked like it worked would lie about who owns the value."
/>
</div>
</Panel>
<Panel
title="Observations"
count={obs.length}
trailing={<span className="dim">bounded ring of distinct failures</span>}
>
{obs.length ? (
<div className="panel-body">
{h.last_error && (
<p className="gap-note obs-latest">
latest: {h.last_error}
{!absent(h.error_at) && `${clock(h.error_at)}`}
</p>
)}
<Observations items={obs} />
</div>
) : (
<Empty title="No failures recorded">
<p>The worker has reported nothing to explain. Repeats would collapse here with a count.</p>
</Empty>
)}
</Panel>
</div>
)
}
export function Workers() {
const workers = useQuery({ queryKey: ['workers'], queryFn: api.workers, refetchInterval: 5000 })
const overview = useQuery({ queryKey: ['overview'], queryFn: api.overview, refetchInterval: 5000 })
const [picked, setPicked] = useState<string>()
const list = workers.data ?? []
const selected = list.find((w) => w.id === picked) ?? list[0]
const online = list.filter((w) => w.online)
const busy = online.filter((w) => w.health.active_task_id)
const faulted = list.filter((w) => health(w) === 'error' || health(w) === 'degraded')
const capacity = online.reduce((n, w) => n + (w.capacity || 0), 0)
return (
<main className="page workers">
<div className="page-head">
<div>
<h1>Workers</h1>
<p>Where work can run, and whether that machinery is usable.</p>
</div>
</div>
{workers.isError ? (
<Panel>
<Empty title="Worker list unavailable">
<p className="mono">GET /v1/federation/workers failed. Nothing can be leased until it answers.</p>
</Empty>
</Panel>
) : (
<>
<div className="grid stats">
<div className="stat">
<span className="label">Online</span>
<span className="stat-value">{online.length}</span>
</div>
<div className="stat">
<span className="label">Busy</span>
<span className="stat-value">{busy.length}</span>
</div>
<div className="stat">
<span className="label">Offline</span>
<span className="stat-value">{list.length - online.length}</span>
</div>
<div className="stat">
<span className="label">Faulted</span>
<span className="stat-value">{faulted.length}</span>
</div>
<div className="stat">
<span className="label">Free capacity</span>
<span className="stat-value">{capacity - busy.length}</span>
</div>
</div>
<div className="workers-split">
<Panel title="Fleet" count={list.length}>
{list.length === 0 ? (
<Empty title="No workers registered">
<p>
A worker registers itself: it starts with coordinator credentials, announces its
id, revision, harnesses, projects and capacity, then keeps liveness with a
heartbeat. Nothing here can create one.
</p>
</Empty>
) : (
<div className="table-scroll">
<table className="table">
<thead>
<tr>
<th>Worker</th>
<th>Status</th>
<th>Backend</th>
<th>Current task</th>
<th>Pane</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{list.map((w) => {
const h = w.health as Health2
return (
<tr
key={w.id}
className="worker-row"
aria-selected={w.id === selected?.id}
onClick={() => setPicked(w.id)}
>
<td className="mono">{w.id}</td>
<td>
<span className="status-cell">
<Dot health={health(w)} /> {statusLabel(w)}
</span>
</td>
<td>
<Chip>
<span className="mono">{h.backend || 'unknown'}</span>
</Chip>
</td>
<td>
{h.active_task_id ? (
<Link
className="task-link"
to={`/tasks/${h.active_task_id}`}
onClick={(e) => e.stopPropagation()}
>
<M>{h.active_task_id}</M>
</Link>
) : (
<span className="dim mono"></span>
)}
</td>
<td className="mono">{h.active_pane_id || '—'}</td>
<td className="mono">{ago(w.last_seen)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Panel>
<div className="workers-detail">
{selected && <Detail worker={selected} tasks={overview.data?.tasks ?? []} />}
</div>
</div>
<Panel title="Registration">
<div className="panel-body registration">
<p className="reg-lead">
<Icon name="worker" size={16} /> Workers register themselves; the console only reads
what they announce.
</p>
<ol className="steps">
<li>The worker starts with coordinator credentials.</li>
<li>It announces id, revision, harnesses, projects and capacity.</li>
<li>The coordinator records it; heartbeats keep liveness.</li>
</ol>
<EndpointGap
path="POST /v1/federation/workers"
what="There is no browser-side worker creation and no drain or unregister endpoint. Buttons for them would be decoration over machinery that does not exist."
/>
</div>
</Panel>
</>
)}
</main>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { NAV } from './Shell'
/** The shared command surface. It navigates and nothing else: an operator
* console should not hide state changes behind a search box. */
export function CommandPalette({
onClose,
onNavigate,
}: {
onClose: () => void
onNavigate: (to: string) => void
}) {
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const input = useRef<HTMLInputElement>(null)
useEffect(() => input.current?.focus(), [])
const matches = useMemo(() => {
const needle = query.trim().toLowerCase()
return NAV.filter((item) => !needle || item.label.toLowerCase().includes(needle))
}, [query])
useEffect(() => setActive(0), [query])
return (
<div className="cmdk-scrim" onClick={onClose} role="presentation">
<div className="cmdk-panel" onClick={(event) => event.stopPropagation()}>
<input
ref={input}
className="cmdk-input"
placeholder="Search tasks, decisions, workers…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') setActive((i) => Math.min(i + 1, matches.length - 1))
if (event.key === 'ArrowUp') setActive((i) => Math.max(i - 1, 0))
if (event.key === 'Enter' && matches[active]) onNavigate(matches[active].to)
}}
/>
<div className="cmdk-list">
{matches.map((item, i) => (
<button
key={item.to}
className="cmdk-row"
data-active={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => onNavigate(item.to)}
>
{item.label}
</button>
))}
{matches.length === 0 && (
<p style={{ padding: '14px 10px', color: 'var(--text-mid)', margin: 0 }}>
Nothing matches that.
</p>
)}
</div>
</div>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { Icon } from '../components/Icon'
import { CommandPalette } from './CommandPalette'
export const NAV = [
{ to: '/', icon: 'home', label: 'Dashboard', end: true },
{ to: '/tasks', icon: 'task', label: 'Tasks' },
{ to: '/decisions', icon: 'decision', label: 'Decisions' },
{ to: '/workers', icon: 'worker', label: 'Workers' },
{ to: '/projects', icon: 'project', label: 'Projects' },
{ to: '/review', icon: 'review', label: 'Review' },
{ to: '/settings', icon: 'settings', label: 'Settings', secondary: true },
]
/** Machine readouts belong in the top bar on desktop. On mobile they relocate
* into the detail screens rather than disappearing: honesty moves, it is
* never deleted. */
function Clock() {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
return <span>{now.toISOString().slice(11, 19)} UTC</span>
}
export function Shell({ revision, live }: { revision?: string; live?: boolean }) {
const [palette, setPalette] = useState(false)
const navigate = useNavigate()
useEffect(() => {
const onShortcut = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault()
setPalette((open) => !open)
}
if (event.key === 'Escape') setPalette(false)
}
window.addEventListener('keydown', onShortcut)
return () => window.removeEventListener('keydown', onShortcut)
}, [])
return (
<div className="console shell">
<nav className="rail" aria-label="Primary">
<span className="rail-mark">
<Icon name="fork" size={26} />
</span>
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className="rail-item"
data-secondary={item.secondary ? 'true' : undefined}
title={item.label}
aria-label={item.label}
>
<Icon name={item.icon} />
</NavLink>
))}
<span className="rail-spacer" />
</nav>
<div className="main">
<header className="topbar">
<span className="topbar-title">orchestra</span>
<button className="cmdk-trigger" onClick={() => setPalette(true)}>
<kbd>K</kbd>
<span>Search tasks, decisions, workers</span>
</button>
<div className="topbar-readouts">
{revision && <span title={revision}>{revision.slice(0, 7)}</span>}
<span>
<span className="dot" data-health={live ? 'healthy' : 'unknown'} />{' '}
{live ? 'Live' : 'Unknown'}
</span>
<Clock />
</div>
</header>
<Outlet />
</div>
{palette && (
<CommandPalette
onClose={() => setPalette(false)}
onNavigate={(to) => {
setPalette(false)
navigate(to)
}}
/>
)}
</div>
)
}
+498 -498
View File
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
/*
ETHOS tokens
Shared design language for the kvmx.ru apps.
The neutral system (ramp, type, motion, radii, shadow) is shared by
every app and does not change. The only per-app difference is the
accent (one hue) and the motif (one geometric signature).
NEW APP: do not invent the accent silently. Ask the operator for the
accent name + hex and the motif, then add one [data-app] block at the
bottom using the TEMPLATE. Fonts are self-hosted (subset + woff2,
immutable caching) no font CDN.
*/
@font-face {
font-family: 'Geist';
src: url('/fonts/Geist-Variable.woff2') format('woff2');
font-weight: 100 900; font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/GeistMono-Variable.woff2') format('woff2');
font-weight: 100 900; font-display: swap;
}
:root {
/* ── neutral ramp — warm, brown-tinted (dark, default) ── */
--bg-0: #14110D; /* deepest room */
--bg-1: #1B1712; /* surface */
--bg-2: #221D17; /* raised */
--bg-3: #2C261D; /* hover / raised */
--bg-4: #372F24; /* pressed / high */
--line: rgba(244, 234, 220, 0.09); /* hairline */
--line-hi: rgba(244, 234, 220, 0.16); /* hairline emphasized */
--text-hi: #F4EEE4; /* human primary (sans) */
--text-mid: #B4AA98; /* human secondary (sans) */
--text-lo: #756C5C; /* human tertiary / idle */
--text-machine: #9C917D; /* machine default (mono) */
/* ── type ── */
--sans: 'Geist', -apple-system, system-ui, sans-serif;
--mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
/* mono numerics: apply on any element using --mono */
/* font-feature-settings: "tnum" 1, "zero" 1; letter-spacing: -0.01em; */
/* ── motion — mechanical, no bounce ── */
--ease: cubic-bezier(0.2, 0, 0, 1);
--fast: 130ms;
--med: 170ms;
/* ── radii ── */
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--r-xl: 24px;
/* ── elevation: depth from light, never blur ── */
--shadow-soft: 0 2px 8px rgba(0,0,0,0.35), 0 12px 32px rgba(0,0,0,0.28);
--shadow-lift: 0 4px 14px rgba(0,0,0,0.40), 0 20px 48px rgba(0,0,0,0.34);
/* accent slot
Neutral fallback so an app with no [data-app] is never unstyled.
Real values come from the per-app block below. Accent is a SIGNAL
(active state, focus ring, primary action, one lit detail) never a
fill-everything wash. */
--accent: var(--text-mid);
--accent-hi: var(--text-hi);
--accent-dim: rgba(244, 234, 220, 0.10);
--accent-line: rgba(244, 234, 220, 0.24);
--accent-glow: rgba(244, 234, 220, 0.14);
}
/* ── light theme — warm, off-white (kept off cream to dodge the AI-cream tell) ── */
[data-theme="light"] {
--bg-0: #F1ECE3;
--bg-1: #EAE4D8;
--bg-2: #E2DACB;
--bg-3: #D7CDBB;
--bg-4: #C9BDA7;
--line: rgba(28, 22, 14, 0.10);
--line-hi: rgba(28, 22, 14, 0.18);
--text-hi: #1B1712;
--text-mid: #544C3E;
--text-lo: #877E6C;
--text-machine: #6B6252;
--shadow-soft: 0 2px 8px rgba(60,45,25,0.10), 0 12px 32px rgba(60,45,25,0.08);
--shadow-lift: 0 4px 14px rgba(60,45,25,0.12), 0 20px 48px rgba(60,45,25,0.10);
}
/*
PER-APP OVERRIDES one small block each. Accent + optional motif
hooks only; never touch the neutral ramp.
*/
/* muzick — reference app · honey amber · waveform */
[data-app="muzick"] {
--accent: #EDA24E;
--accent-hi: #F5B667;
--accent-dim: rgba(237, 162, 78, 0.14);
--accent-line: rgba(237, 162, 78, 0.32);
--accent-glow: rgba(237, 162, 78, 0.22);
/* motif hook: cool content-art gradient so art carries color, amber stays signal */
--np-art: radial-gradient(120% 120% at 22% 14%, #7FB3BC 0%, #2F6E7A 42%, #1C4552 78%, #14262E 100%);
}
/* TEMPLATE copy for a new app AFTER intake with the operator
[data-app="APPNAME"] {
--accent: #RRGGBB; // the operator's chosen hue
--accent-hi: #RRGGBB; // ~ +1014% lightness
--accent-dim: rgba(R, G, B, 0.14); // active-state backgrounds
--accent-line: rgba(R, G, B, 0.32); // accent hairlines
--accent-glow: rgba(R, G, B, 0.22); // restrained ambient pool
// optional motif hooks (gradients / seeds) go here, app-specific
}
*/
/* orchestra signal violet · routing fork
Accent and motif come from orchestra-ui-spec.md, chosen by the operator. */
[data-app="orchestra"] {
--accent: #8F7AE5;
--accent-hi: #A291EC;
--accent-dim: rgba(143, 122, 229, 0.14);
--accent-line: rgba(143, 122, 229, 0.32);
--accent-glow: rgba(143, 122, 229, 0.22);
/* motif hook: the routing fork, one path splitting and rejoining. Used by
the app mark, phase transitions and empty states. */
--fork-line: rgba(143, 122, 229, 0.55);
}
+679
View File
@@ -0,0 +1,679 @@
/* Orchestra operator console the ethos shell, applied.
Tokens come from ethos.tokens.css and are never redefined here. This file
holds the shared shell geometry and the primitives every screen reuses, so
nine screens cannot drift into nine dialects. */
@import './ethos.tokens.css';
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--bg-0);
color: var(--text-hi);
font-family: var(--sans);
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
/* Law 1. Anything the machine owns is mono, with tabular figures so columns
of ids, durations and percentages line up instead of shimmering. */
.mono,
code,
kbd,
pre {
font-family: var(--mono);
font-feature-settings: 'tnum' 1, 'zero' 1;
letter-spacing: -0.01em;
color: var(--text-machine);
}
/* Machine ids are long and unbreakable by default, so a pane id ran out of
its card and under the next one. Ids wrap; tables keep their own scroll. */
.console .mono {
overflow-wrap: anywhere;
}
.console .table .mono,
.console .topbar-readouts .mono {
overflow-wrap: normal;
}
.console .label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-lo);
font-weight: 500;
}
.console h1 {
font-size: 28px;
line-height: 1.15;
letter-spacing: -0.02em;
margin: 0;
font-weight: 600;
}
.console h2 {
font-size: 17px;
margin: 0;
font-weight: 600;
letter-spacing: -0.01em;
}
.console a {
color: inherit;
text-decoration: none;
}
.console button {
font: inherit;
color: inherit;
}
/* ── shell ───────────────────────────────────────────────────────────── */
.console.shell {
display: grid;
grid-template-columns: 64px 1fr;
height: 100%;
}
.console .rail {
background: var(--bg-1);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 0 10px;
gap: 4px;
}
.console .rail-mark {
color: var(--accent);
margin-bottom: 14px;
}
.console .rail-item {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--text-lo);
background: none;
border: 0;
cursor: pointer;
transition: color var(--fast) var(--ease), background var(--fast) var(--ease);
}
.console .rail-item:hover {
color: var(--text-mid);
background: var(--bg-2);
}
/* Accent marks the one active thing. It never fills the rail. */
.console .rail-item[aria-current='page'] {
color: var(--accent);
background: var(--accent-dim);
}
.console .rail-item[aria-current='page']::before {
content: '';
position: absolute;
left: -10px;
top: 10px;
bottom: 10px;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--accent);
}
.console .rail-spacer {
flex: 1;
}
.console .topbar {
height: 56px;
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--bg-1);
}
.console .topbar-title {
font-weight: 600;
letter-spacing: -0.01em;
}
.console .topbar-readouts {
margin-left: auto;
display: flex;
align-items: center;
gap: 18px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-machine);
}
.console .main {
display: grid;
grid-template-rows: 56px 1fr;
min-width: 0;
height: 100%;
}
.console .page {
overflow-y: auto;
padding: 22px 24px 40px;
display: flex;
flex-direction: column;
gap: 20px;
}
.console .page-head {
display: flex;
align-items: flex-end;
gap: 14px;
flex-wrap: wrap;
}
.console .page-head p {
margin: 0;
color: var(--text-mid);
}
/* ── command surface ─────────────────────────────────────────────────── */
.console .cmdk-trigger {
display: flex;
align-items: center;
gap: 10px;
height: 34px;
min-width: 260px;
max-width: 520px;
flex: 1;
padding: 0 12px;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
color: var(--text-lo);
cursor: pointer;
transition: border-color var(--fast) var(--ease);
}
.console .cmdk-trigger:hover {
border-color: var(--line-hi);
}
kbd {
font-size: 11px;
padding: 2px 6px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--bg-1);
}
.console .cmdk-scrim {
position: fixed;
inset: 0;
background: rgba(10, 8, 6, 0.62);
display: grid;
place-items: start center;
padding-top: 12vh;
z-index: 40;
}
.console .cmdk-panel {
width: min(620px, 92vw);
background: var(--bg-1);
border: 1px solid var(--line-hi);
border-radius: var(--r-md);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.console .cmdk-input {
width: 100%;
height: 50px;
padding: 0 16px;
background: none;
border: 0;
border-bottom: 1px solid var(--line);
color: var(--text-hi);
font: inherit;
outline: none;
}
.console .cmdk-list {
max-height: 46vh;
overflow-y: auto;
padding: 6px;
}
.console .cmdk-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
border: 0;
border-radius: var(--r-sm);
background: none;
text-align: left;
cursor: pointer;
}
.console .cmdk-row[data-active='true'] {
background: var(--bg-3);
}
/* ── panels and tables ───────────────────────────────────────────────── */
.console .panel {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.console .panel-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
}
.console .panel-head .count {
font-family: var(--mono);
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
background: var(--bg-3);
color: var(--text-machine);
}
.console .panel-head .trailing {
margin-left: auto;
color: var(--text-lo);
font-size: 13px;
}
.console .panel-body {
padding: 14px 18px;
}
.console .rows {
display: flex;
flex-direction: column;
}
.console .row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 18px;
border-bottom: 1px solid var(--line);
text-align: left;
background: none;
border-left: 0;
border-right: 0;
border-top: 0;
width: 100%;
cursor: pointer;
transition: background var(--fast) var(--ease);
}
.console .row:last-child {
border-bottom: 0;
}
.console .row:hover {
background: var(--bg-2);
}
.console .row-main {
min-width: 0;
flex: 1;
}
.console .row-title {
font-weight: 500;
}
.console .row-sub {
color: var(--text-mid);
font-size: 13px;
}
.console .table {
width: 100%;
border-collapse: collapse;
}
.console .table th {
text-align: left;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-lo);
font-weight: 500;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.console .table td {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.console .table tbody tr:last-child td {
border-bottom: 0;
}
.console .table tbody tr:hover {
background: var(--bg-2);
}
.console .table-scroll {
overflow-x: auto;
}
/* ── status, phases, values ──────────────────────────────────────────── */
.console .dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
flex: none;
}
.console .dot[data-health='healthy'] {
background: #5fbf82;
}
.console .dot[data-health='degraded'] {
background: #d8a657;
}
.console .dot[data-health='error'] {
background: #e06c60;
}
.console .dot[data-health='unknown'] {
background: var(--text-lo);
}
.console .chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 9px;
border-radius: 6px;
font-size: 12px;
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--text-mid);
white-space: nowrap;
}
.console .chip[data-tone='accent'] {
border-color: var(--accent-line);
background: var(--accent-dim);
color: var(--accent-hi);
}
.console .chip[data-tone='warn'] {
border-color: rgba(216, 166, 87, 0.32);
background: rgba(216, 166, 87, 0.12);
color: #d8a657;
}
.console .chip[data-tone='fault'] {
border-color: rgba(224, 108, 96, 0.32);
background: rgba(224, 108, 96, 0.12);
color: #e06c60;
}
.console .chip[data-tone='done'] {
border-color: rgba(95, 191, 130, 0.28);
background: rgba(95, 191, 130, 0.1);
color: #5fbf82;
}
/* The routing fork, rendered as the phase path. Completed is quiet green,
the current phase carries the accent, the future stays neutral. */
.console .phases {
display: flex;
align-items: center;
gap: 0;
}
.console .phase-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 46px;
}
.console .phase-node {
width: 9px;
height: 9px;
border-radius: 50%;
border: 1.6px solid var(--text-lo);
}
.console .phase-step[data-state='done'] .phase-node {
border-color: #5fbf82;
background: rgba(95, 191, 130, 0.35);
}
.console .phase-step[data-state='current'] .phase-node {
border-color: var(--accent);
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.console .phase-step .phase-name {
font-size: 9px;
color: var(--text-lo);
letter-spacing: 0.04em;
}
.console .phase-step[data-state='current'] .phase-name {
color: var(--accent-hi);
}
.console .phase-link {
height: 1.6px;
width: 18px;
background: var(--line-hi);
margin-bottom: 12px;
}
.console .phase-link[data-state='done'] {
background: rgba(95, 191, 130, 0.4);
}
.console .meter {
height: 4px;
border-radius: 2px;
background: var(--bg-3);
overflow: hidden;
min-width: 64px;
}
.console .meter > span {
display: block;
height: 100%;
background: var(--accent);
}
/* ── actions ─────────────────────────────────────────────────────────── */
.console .btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 13px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--bg-2);
color: var(--text-hi);
cursor: pointer;
transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease);
}
.console .btn:hover {
background: var(--bg-3);
border-color: var(--line-hi);
}
.console .btn[data-variant='primary'] {
background: var(--accent-dim);
border-color: var(--accent-line);
color: var(--accent-hi);
}
.console .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
/* ── empty, error, gap ───────────────────────────────────────────────── */
.console .empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 44px 20px;
text-align: center;
color: var(--text-mid);
}
.console .empty .motif {
color: var(--accent);
opacity: 0.55;
}
.console .empty h3 {
margin: 0;
font-size: 15px;
color: var(--text-hi);
font-weight: 600;
}
/* A screen with no endpoint says so, with the path it would call. Law 5: an
interface never implies data it does not have. */
.console .gap-note {
font-family: var(--mono);
font-size: 12px;
color: var(--text-machine);
background: var(--bg-2);
border: 1px dashed var(--line-hi);
border-radius: var(--r-sm);
padding: 8px 12px;
}
.console .grid {
display: grid;
gap: 14px;
}
.console .stats {
grid-template-columns: repeat(auto-fit, minmax(184px, 1fr));
}
.console .stat {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.console .stat-value {
font-family: var(--mono);
font-size: 26px;
color: var(--text-hi);
letter-spacing: -0.02em;
}
/* ── mobile: rail becomes a bottom tab bar, readouts relocate ────────── */
@media (max-width: 640px) {
.console.shell {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
}
.console .rail {
order: 2;
flex-direction: row;
justify-content: space-around;
width: 100%;
padding: 4px 0;
border-right: 0;
border-top: 1px solid var(--line);
}
.rail-mark,
.rail-spacer,
.console .rail-item[data-secondary='true'] {
display: none;
}
.console .rail-item[aria-current='page']::before {
left: 10px;
right: 10px;
top: -4px;
bottom: auto;
width: auto;
height: 3px;
border-radius: 0 0 2px 2px;
}
.console .topbar-readouts {
display: none;
}
.console .page {
padding: 16px 14px 28px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}