wip: preserve mixed agent dashboard experiments
@@ -0,0 +1,226 @@
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url("../src/renderer/src/assets/fonts/Geist-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #fff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #fff;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--border: #e5e5e5;
|
||||
--ring: #a1a1a1;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fafafa;
|
||||
--status-success: #15803d;
|
||||
--attention: #f59e0b;
|
||||
--danger: #e40014;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #171717;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #404040;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--ring: #737373;
|
||||
--primary: #e5e5e5;
|
||||
--primary-foreground: #171717;
|
||||
--status-success: #86efac;
|
||||
--danger: #ff6568;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; background: var(--background); color: var(--foreground); font: 14px/1.45 Geist, sans-serif; letter-spacing: .01em; }
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
.svg-defs { position: absolute; width: 0; height: 0; }
|
||||
.icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.prototype-shell { min-width: 1120px; height: 100vh; min-height: 720px; overflow: hidden; background: var(--background); }
|
||||
.app-header { height: 64px; display: flex; align-items: center; gap: 18px; padding: 0 20px; border-bottom: 1px solid var(--border); }
|
||||
.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: var(--primary); color: var(--primary-foreground); font-weight: 800; }
|
||||
.title-block h1 { margin: 0; font-size: 18px; line-height: 1.1; }
|
||||
.title-block span { color: var(--muted-foreground); font-size: 11px; }
|
||||
.view-tabs { display: flex; align-items: center; gap: 2px; margin-left: 30px; padding: 3px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.view-tab, .period-tabs button { border: 0; border-radius: 6px; background: transparent; color: var(--muted-foreground); padding: 7px 18px; cursor: pointer; }
|
||||
.view-tab:hover, .period-tabs button:hover { background: var(--muted); color: var(--foreground); }
|
||||
.view-tab.is-active, .period-tabs button.is-active { background: var(--muted); color: var(--foreground); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.header-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.search-box { width: 300px; height: 34px; display: flex; align-items: center; gap: 8px; padding: 0 9px; border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--muted) 55%, transparent); color: var(--muted-foreground); }
|
||||
.search-box input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--foreground); }
|
||||
kbd { padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; font-size: 10px; }
|
||||
.button, .icon-button, .text-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 0; cursor: pointer; }
|
||||
.button { height: 34px; padding: 0 12px; border-radius: 7px; }
|
||||
.button-outline, .button-ghost { border: 1px solid var(--border); background: var(--card); }
|
||||
.button-ghost { background: transparent; }
|
||||
.button-outline:hover, .button-ghost:hover { background: var(--muted); }
|
||||
.button-primary { background: var(--primary); color: var(--primary-foreground); }
|
||||
.button-small { height: 28px; padding: 0 9px; font-size: 12px; }
|
||||
.icon-button { width: 32px; height: 32px; border-radius: 7px; border: 1px solid var(--border); background: transparent; }
|
||||
.icon-button:hover { background: var(--muted); }
|
||||
.text-button { padding: 3px; background: transparent; color: var(--muted-foreground); font-size: 12px; }
|
||||
.text-button:hover { color: var(--foreground); }
|
||||
.dashboard-screen { display: none; height: calc(100vh - 64px); min-height: 656px; }
|
||||
.dashboard-screen.is-active { display: block; animation: reveal .18s ease-out; }
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(2px); } }
|
||||
|
||||
.overview-summary { height: 72px; display: grid; grid-template-columns: repeat(4, 1fr); align-items: center; margin: 16px 18px 0; border: 1px solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--card) 72%, transparent); }
|
||||
.summary-item { min-height: 34px; display: flex; align-items: center; justify-content: center; gap: 8px; border-right: 1px solid var(--border); color: var(--muted-foreground); background: transparent; border-top: 0; border-bottom: 0; border-left: 0; }
|
||||
.summary-item:last-child { border-right: 0; }
|
||||
.summary-item strong { color: var(--foreground); font-size: 16px; font-variant-numeric: tabular-nums; }
|
||||
.summary-item.attention { cursor: pointer; }
|
||||
.summary-item.attention:hover { background: color-mix(in srgb, var(--attention) 6%, transparent); }
|
||||
.summary-glyph { display: grid; place-items: center; width: 19px; height: 19px; border: 1px solid var(--attention); border-radius: 50%; color: var(--attention); font-weight: 700; }
|
||||
.summary-item.success .icon { color: var(--status-success); }
|
||||
.working-spinner { width: 10px; height: 10px; flex: 0 0 auto; border: 1.5px solid var(--muted-foreground); border-right-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
.idle-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted-foreground); opacity: .55; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.overview-layout { height: calc(100% - 88px); display: grid; grid-template-columns: minmax(720px, 1fr) 330px; padding: 0 18px 18px; }
|
||||
.overview-main { min-width: 0; padding: 20px 20px 0 0; overflow: auto; }
|
||||
.content-section + .content-section { margin-top: 24px; }
|
||||
.section-heading, .panel-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
|
||||
.section-heading > div { display: flex; align-items: center; gap: 7px; }
|
||||
.eyebrow { color: var(--muted-foreground); font-size: 11px; font-weight: 650; letter-spacing: .055em; text-transform: uppercase; }
|
||||
.count-badge { min-width: 20px; padding: 1px 6px; border-radius: 999px; background: var(--muted); color: var(--muted-foreground); font-size: 11px; text-align: center; }
|
||||
.attention-list, .mission-group { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--card) 82%, transparent); }
|
||||
.attention-row { min-height: 67px; display: grid; grid-template-columns: 30px minmax(260px, 1fr) 38px 66px 66px; gap: 9px; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.attention-row:last-child { border-bottom: 0; }
|
||||
.attention-row:hover { background: color-mix(in srgb, var(--attention) 5%, transparent); }
|
||||
.state-icon, .activity-icon { display: grid; place-items: center; border-radius: 7px; background: var(--muted); color: var(--muted-foreground); }
|
||||
.state-icon { width: 28px; height: 28px; }
|
||||
.state-icon.attention, .activity-icon.attention { color: var(--attention); background: color-mix(in srgb, var(--attention) 10%, transparent); }
|
||||
.row-copy { min-width: 0; display: flex; flex-direction: column; }
|
||||
.row-copy strong { font-size: 13px; }
|
||||
.row-copy span, .relative-time, .mission-header span, .muted { color: var(--muted-foreground); font-size: 11px; }
|
||||
.row-copy b { color: var(--foreground); font-weight: 500; }
|
||||
.missions-section { padding-bottom: 30px; }
|
||||
.mission-group + .mission-group { margin-top: 10px; }
|
||||
.mission-header { min-height: 52px; display: flex; align-items: center; gap: 10px; padding: 8px 13px; }
|
||||
.mission-header > div { display: flex; flex-direction: column; min-width: 220px; }
|
||||
.mission-header .mission-progress { width: 88px; height: 4px; margin-left: auto; overflow: hidden; border-radius: 4px; background: var(--muted); }
|
||||
.mission-progress i { display: block; width: var(--progress); height: 100%; background: var(--muted-foreground); opacity: .7; }
|
||||
.agent-row { width: 100%; min-height: 49px; display: grid; grid-template-columns: 12px 26px 130px minmax(220px, 1fr) 62px 100px 14px; align-items: center; gap: 9px; padding: 5px 13px; border: 0; border-top: 1px solid var(--border); background: transparent; text-align: left; cursor: pointer; }
|
||||
.agent-row:hover, .agent-row.is-selected { background: color-mix(in srgb, var(--accent) 40%, transparent); }
|
||||
.agent-row.is-selected { box-shadow: inset 2px 0 var(--ring); }
|
||||
.agent-avatar { display: grid; place-items: center; width: 25px; height: 25px; border: 1px solid var(--border); border-radius: 6px; background: var(--muted); font-size: 11px; font-weight: 700; }
|
||||
.agent-name { font-weight: 600; }
|
||||
.agent-action, .row-meta { color: var(--muted-foreground); font-size: 12px; }
|
||||
.agent-action { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-meta { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-meta .icon { width: 13px; height: 13px; }
|
||||
.chevron { color: var(--muted-foreground); font-size: 18px; }
|
||||
.collapsed .mission-header { cursor: pointer; }
|
||||
.activity-panel { min-width: 0; padding: 20px 0 0 20px; border-left: 1px solid var(--border); }
|
||||
.activity-panel .panel-heading { padding-right: 4px; }
|
||||
.activity-feed { border-top: 1px solid var(--border); }
|
||||
.activity-item { min-height: 67px; display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; gap: 10px; border-bottom: 1px solid var(--border); }
|
||||
.activity-icon { width: 30px; height: 30px; }
|
||||
.activity-icon.success { color: var(--status-success); background: color-mix(in srgb, var(--status-success) 10%, transparent); }
|
||||
.activity-item > div { min-width: 0; display: flex; flex-direction: column; }
|
||||
.activity-item strong { font-size: 12px; }
|
||||
.activity-item span, .activity-item time { color: var(--muted-foreground); font-size: 11px; }
|
||||
.activity-item time { align-self: start; padding-top: 18px; text-align: right; }
|
||||
.panel-footer { width: 100%; padding: 14px; border: 0; background: transparent; color: var(--muted-foreground); cursor: pointer; }
|
||||
.panel-footer:hover { color: var(--foreground); }
|
||||
|
||||
.map-layout { height: 100%; display: grid; grid-template-columns: 220px minmax(660px, 1fr) 320px; }
|
||||
.attention-rail, .agent-inspector { background: color-mix(in srgb, var(--card) 72%, var(--background)); }
|
||||
.attention-rail { padding: 18px 12px; border-right: 1px solid var(--border); }
|
||||
.attention-rail > .eyebrow { display: block; padding: 0 6px 10px; }
|
||||
.rail-alert { width: 100%; display: flex; align-items: flex-start; gap: 9px; padding: 9px; border: 1px solid color-mix(in srgb, var(--attention) 28%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--attention) 7%, transparent); text-align: left; cursor: pointer; }
|
||||
.rail-alert + .rail-alert { margin-top: 7px; }
|
||||
.rail-alert > span { color: var(--attention); font-weight: 700; }
|
||||
.rail-alert div { min-width: 0; display: flex; flex-direction: column; }
|
||||
.rail-alert strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-alert small { color: var(--muted-foreground); }
|
||||
.agent-canvas { position: relative; overflow: hidden; background-color: var(--background); background-image: radial-gradient(color-mix(in srgb, var(--muted-foreground) 20%, transparent) .7px, transparent .7px); background-size: 18px 18px; }
|
||||
.canvas-toolbar { position: absolute; z-index: 4; bottom: 16px; left: 16px; display: flex; align-items: center; gap: 5px; padding: 5px; border: 1px solid var(--border); border-radius: 9px; background: var(--card); box-shadow: 0 10px 24px rgb(0 0 0 / .18); }
|
||||
.canvas-toolbar > span { padding: 0 5px; color: var(--muted-foreground); font-size: 11px; }
|
||||
.canvas-toolbar .icon-button { width: 27px; height: 27px; }
|
||||
.mission-cluster { position: absolute; inset: 40px 34px 75px; min-height: 500px; border: 1px dashed color-mix(in srgb, var(--muted-foreground) 34%, transparent); border-radius: 14px; }
|
||||
.mission-cluster > header { height: 48px; display: flex; align-items: center; gap: 8px; padding: 0 14px; color: var(--muted-foreground); }
|
||||
.mission-cluster > header strong { color: var(--foreground); }
|
||||
.mission-cluster > header span { margin-left: auto; font-size: 11px; }
|
||||
.connectors { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.connectors path { fill: none; stroke: color-mix(in srgb, var(--muted-foreground) 58%, transparent); stroke-width: 1.2; vector-effect: non-scaling-stroke; }
|
||||
.map-node { position: absolute; z-index: 2; display: grid; grid-template-columns: 17px 1fr; gap: 1px 8px; align-items: center; width: 178px; min-height: 62px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 9px; background: var(--card); text-align: left; cursor: pointer; box-shadow: 0 4px 15px rgb(0 0 0 / .08); }
|
||||
.map-node:hover { background: color-mix(in srgb, var(--card) 70%, var(--accent)); }
|
||||
.map-node.is-selected { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
.map-node strong, .map-node small { grid-column: 2; }
|
||||
.map-node strong { font-size: 12px; }
|
||||
.map-node small { color: var(--muted-foreground); }
|
||||
.mission-node { top: 72px; left: calc(50% - 105px); width: 210px; }
|
||||
.node-ui { top: 205px; left: calc(19% - 89px); }
|
||||
.node-api { top: 205px; left: calc(50% - 89px); }
|
||||
.node-e2e { top: 205px; left: calc(81% - 89px); }
|
||||
.mini-node { top: 350px; width: 130px; min-height: 52px; grid-template-columns: 1fr; }
|
||||
.mini-node strong, .mini-node small { grid-column: 1; }
|
||||
.node-child-a { left: calc(19% - 145px); }
|
||||
.node-child-b { left: calc(19% + 15px); }
|
||||
.output-node { top: 365px; left: calc(50% - 76px); width: 152px; min-height: 52px; }
|
||||
.agent-inspector { display: flex; flex-direction: column; gap: 18px; padding: 18px; border-left: 1px solid var(--border); overflow: auto; }
|
||||
.inspector-heading { display: flex; align-items: center; gap: 10px; }
|
||||
.inspector-heading > div { display: flex; flex: 1; flex-direction: column; }
|
||||
.inspector-heading > div span { color: var(--muted-foreground); font-size: 11px; }
|
||||
.inspector-heading .icon-button { border: 0; font-size: 20px; }
|
||||
.status-pill, .outcome { width: fit-content; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; padding: 4px 9px; font-size: 11px; }
|
||||
.inspector-facts { display: grid; grid-template-columns: 1fr 66px; gap: 8px; margin: 0; }
|
||||
.inspector-facts > div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.inspector-facts dt { color: var(--muted-foreground); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.inspector-facts dd { margin: 0; font-size: 12px; }
|
||||
.inspector-section { padding-top: 17px; border-top: 1px solid var(--border); }
|
||||
.mini-timeline p { display: grid; grid-template-columns: 8px 1fr 30px; gap: 8px; align-items: center; margin: 13px 0; color: var(--muted-foreground); font-size: 11px; }
|
||||
.mini-timeline i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted-foreground); opacity: .5; }
|
||||
.mini-timeline time { text-align: right; }
|
||||
.metadata-list p { display: flex; justify-content: space-between; margin: 10px 0; font-size: 11px; }
|
||||
.metadata-list span { color: var(--muted-foreground); text-transform: uppercase; }
|
||||
.metadata-list strong { font-weight: 500; }
|
||||
.inspector-actions { display: grid; gap: 7px; margin-top: auto; }
|
||||
|
||||
.history-header { display: flex; align-items: flex-end; justify-content: space-between; padding: 30px 34px 22px; }
|
||||
.history-header h2 { margin: 6px 0 3px; font-size: 22px; }
|
||||
.history-header p { margin: 0; color: var(--muted-foreground); }
|
||||
.history-metrics { display: flex; gap: 28px; }
|
||||
.history-metrics span { display: flex; flex-direction: column; color: var(--muted-foreground); font-size: 11px; }
|
||||
.history-metrics strong { color: var(--foreground); font-size: 16px; }
|
||||
.history-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 34px 14px; }
|
||||
.period-tabs { display: flex; gap: 2px; margin-right: auto; padding: 3px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.period-tabs button { padding: 5px 13px; font-size: 12px; }
|
||||
.history-table-wrap { margin: 0 34px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); }
|
||||
.history-table { width: 100%; border-collapse: collapse; table-layout: fixed; background: color-mix(in srgb, var(--card) 82%, transparent); }
|
||||
.history-table th { height: 38px; padding: 0 12px; background: color-mix(in srgb, var(--muted) 60%, transparent); color: var(--muted-foreground); font-size: 10px; font-weight: 600; letter-spacing: .05em; text-align: left; text-transform: uppercase; }
|
||||
.history-table td { height: 62px; padding: 7px 12px; border-top: 1px solid var(--border); color: var(--muted-foreground); font-size: 12px; }
|
||||
.history-table tr:hover td { background: color-mix(in srgb, var(--accent) 36%, transparent); }
|
||||
.history-table th:nth-child(1) { width: 15%; }.history-table th:nth-child(2) { width: 20%; }.history-table th:nth-child(3) { width: 16%; }
|
||||
.history-table th:nth-child(4) { width: 10%; }.history-table th:nth-child(5) { width: 8%; }.history-table th:nth-child(6) { width: 12%; }
|
||||
.history-table th:nth-child(7) { width: 15%; }.history-table th:last-child { width: 4%; }
|
||||
.history-table td:first-child { display: flex; align-items: center; gap: 8px; color: var(--foreground); }
|
||||
.history-table td:nth-child(2) { color: var(--foreground); }
|
||||
.history-table td:nth-child(3) { display: table-cell; }
|
||||
.history-table td:nth-child(3) strong, .history-table td:nth-child(3) small { display: block; }
|
||||
.history-table td:nth-child(3) strong { color: var(--foreground); }
|
||||
.history-table td:nth-child(3) small { color: var(--muted-foreground); }
|
||||
.outcome { padding: 3px 7px; border-color: transparent; }
|
||||
.outcome.success { color: var(--status-success); background: color-mix(in srgb, var(--status-success) 10%, transparent); }
|
||||
.outcome.interrupted { color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, transparent); }
|
||||
.outcome .icon { width: 13px; height: 13px; }
|
||||
.review-chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px; border: 1px solid var(--border); border-radius: 999px; color: var(--foreground); }
|
||||
.review-chip .icon { width: 12px; height: 12px; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.search-box { width: 220px; }
|
||||
.title-block span { display: none; }
|
||||
.map-layout { grid-template-columns: 190px minmax(600px, 1fr) 290px; }
|
||||
.overview-layout { grid-template-columns: minmax(690px, 1fr) 300px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Agent Dashboard Concepts</title>
|
||||
<link rel="stylesheet" href="./agent-dashboard-concepts.css" />
|
||||
</head>
|
||||
<body class="dark">
|
||||
<svg class="svg-defs" aria-hidden="true">
|
||||
<symbol id="i-search" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24">
|
||||
<path d="M4 5h16M7 12h10M10 19h4"></path>
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24">
|
||||
<path d="M3 7h6l2 2h10v10H3z"></path>
|
||||
</symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.9"></path>
|
||||
</symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path>
|
||||
</symbol>
|
||||
<symbol id="i-branch" viewBox="0 0 24 24">
|
||||
<circle cx="6" cy="5" r="2"></circle><circle cx="18" cy="6" r="2"></circle>
|
||||
<circle cx="6" cy="19" r="2"></circle><path d="M6 7v10M8 9c5 0 4-3 8-3"></path>
|
||||
</symbol>
|
||||
<symbol id="i-message" viewBox="0 0 24 24">
|
||||
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"></path>
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9"></circle><path d="m8 12 3 3 5-6"></path>
|
||||
</symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24">
|
||||
<path d="m4 6 5 5-5 5M12 18h8"></path>
|
||||
</symbol>
|
||||
<symbol id="i-layout" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 9v12"></path>
|
||||
</symbol>
|
||||
<symbol id="i-sun" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path>
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<main class="prototype-shell">
|
||||
<header class="app-header">
|
||||
<div class="brand-mark" aria-hidden="true">O</div>
|
||||
<div class="title-block">
|
||||
<h1>Agents</h1>
|
||||
<span>12 across 4 projects</span>
|
||||
</div>
|
||||
<nav class="view-tabs" aria-label="Dashboard view">
|
||||
<button class="view-tab is-active" data-view="overview">Overview</button>
|
||||
<button class="view-tab" data-view="map">Map</button>
|
||||
<button class="view-tab" data-view="history">History</button>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<label class="search-box">
|
||||
<svg class="icon"><use href="#i-search"></use></svg>
|
||||
<input aria-label="Search agents" placeholder="Search agents, projects, or work…" />
|
||||
<kbd>⌘ K</kbd>
|
||||
</label>
|
||||
<button class="button button-outline">
|
||||
<svg class="icon"><use href="#i-filter"></use></svg>
|
||||
Filter
|
||||
</button>
|
||||
<button class="icon-button" id="theme-toggle" aria-label="Toggle color theme">
|
||||
<svg class="icon"><use href="#i-sun"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="dashboard-screen is-active" data-screen="overview">
|
||||
<div class="overview-summary">
|
||||
<button class="summary-item attention">
|
||||
<span class="summary-glyph">!</span><strong>2</strong><span>need you</span>
|
||||
</button>
|
||||
<div class="summary-item"><span class="working-spinner"></span><strong>6</strong><span>active</span></div>
|
||||
<div class="summary-item success">
|
||||
<svg class="icon"><use href="#i-check"></use></svg><strong>3</strong><span>completed today</span>
|
||||
</div>
|
||||
<div class="summary-item"><span class="idle-dot"></span><strong>1</strong><span>idle</span></div>
|
||||
</div>
|
||||
|
||||
<div class="overview-layout">
|
||||
<div class="overview-main">
|
||||
<section class="content-section">
|
||||
<div class="section-heading">
|
||||
<div><span class="eyebrow">Needs you</span><span class="count-badge">2</span></div>
|
||||
<button class="text-button">Mark all seen</button>
|
||||
</div>
|
||||
<div class="attention-list">
|
||||
<article class="attention-row">
|
||||
<div class="state-icon attention"><svg class="icon"><use href="#i-message"></use></svg></div>
|
||||
<div class="row-copy">
|
||||
<strong>Choose token refresh behavior</strong>
|
||||
<span><b>API Integrator</b> · Orca / auth-refresh</span>
|
||||
</div>
|
||||
<span class="relative-time">1m</span>
|
||||
<button class="button button-ghost">Open</button>
|
||||
<button class="button button-primary">Reply</button>
|
||||
</article>
|
||||
<article class="attention-row">
|
||||
<div class="state-icon attention">!</div>
|
||||
<div class="row-copy">
|
||||
<strong>Rate limit strategy exceeds quota</strong>
|
||||
<span><b>Backend Agent</b> · Relay / usage-limits</span>
|
||||
</div>
|
||||
<span class="relative-time">6m</span>
|
||||
<button class="button button-ghost">Open</button>
|
||||
<button class="button button-primary">Reply</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-section missions-section">
|
||||
<div class="section-heading">
|
||||
<div><span class="eyebrow">Active missions</span><span class="count-badge">3</span></div>
|
||||
<button class="button button-outline button-small"><svg class="icon"><use href="#i-plus"></use></svg>New agent</button>
|
||||
</div>
|
||||
|
||||
<article class="mission-group">
|
||||
<header class="mission-header">
|
||||
<svg class="icon"><use href="#i-folder"></use></svg>
|
||||
<div><strong>Orca / Dashboard revamp</strong><span>albacore · 3 agents</span></div>
|
||||
<span class="mission-progress"><i style="--progress: 68%"></i></span>
|
||||
<span class="muted">68%</span>
|
||||
</header>
|
||||
<button class="agent-row is-selected" data-agent="UI Builder" data-action="Refactoring dashboard information hierarchy">
|
||||
<span class="working-spinner"></span><span class="agent-avatar">C</span>
|
||||
<span class="agent-name">UI Builder</span>
|
||||
<span class="agent-action">Refactoring dashboard information hierarchy</span>
|
||||
<span class="row-meta"><svg class="icon"><use href="#i-clock"></use></svg>28m</span>
|
||||
<span class="row-meta"><svg class="icon"><use href="#i-users"></use></svg>2 subagents</span>
|
||||
<span class="chevron">›</span>
|
||||
</button>
|
||||
<button class="agent-row" data-agent="API Integrator" data-action="Wiring metrics endpoints">
|
||||
<span class="working-spinner"></span><span class="agent-avatar">C</span>
|
||||
<span class="agent-name">API Integrator</span><span class="agent-action">Wiring metrics endpoints</span>
|
||||
<span class="row-meta"><svg class="icon"><use href="#i-clock"></use></svg>19m</span>
|
||||
<span class="row-meta"><svg class="icon"><use href="#i-branch"></use></svg>PR #11342</span>
|
||||
<span class="chevron">›</span>
|
||||
</button>
|
||||
<button class="agent-row" data-agent="E2E Tester" data-action="Running dashboard interaction flows">
|
||||
<span class="working-spinner"></span><span class="agent-avatar">C</span>
|
||||
<span class="agent-name">E2E Tester</span><span class="agent-action">Running dashboard interaction flows</span>
|
||||
<span class="row-meta"><svg class="icon"><use href="#i-clock"></use></svg>11m</span>
|
||||
<span class="row-meta">42 / 58 tests</span><span class="chevron">›</span>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="mission-group collapsed">
|
||||
<header class="mission-header">
|
||||
<svg class="icon"><use href="#i-folder"></use></svg>
|
||||
<div><strong>Relay / Connection recovery</strong><span>relay-reconnect · 2 agents</span></div>
|
||||
<span class="mission-progress"><i style="--progress: 41%"></i></span>
|
||||
<span class="muted">41%</span><span class="chevron">›</span>
|
||||
</header>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="activity-panel">
|
||||
<div class="panel-heading"><strong>Activity</strong><button class="text-button">All activity⌄</button></div>
|
||||
<div class="activity-feed">
|
||||
<article class="activity-item"><span class="activity-icon success"><svg class="icon"><use href="#i-check"></use></svg></span><div><strong>E2E tests passed</strong><span>Dashboard filters · E2E Tester</span></div><time>4m</time></article>
|
||||
<article class="activity-item"><span class="activity-icon"><svg class="icon"><use href="#i-branch"></use></svg></span><div><strong>PR #11342 opened</strong><span>metrics-endpoints · API Integrator</span></div><time>8m</time></article>
|
||||
<article class="activity-item"><span class="activity-icon attention"><svg class="icon"><use href="#i-message"></use></svg></span><div><strong>Needs your reply</strong><span>Token refresh behavior</span></div><time>11m</time></article>
|
||||
<article class="activity-item"><span class="activity-icon"><svg class="icon"><use href="#i-terminal"></use></svg></span><div><strong>Agent started</strong><span>Dashboard interaction flows</span></div><time>16m</time></article>
|
||||
<article class="activity-item"><span class="activity-icon success"><svg class="icon"><use href="#i-check"></use></svg></span><div><strong>Review merged</strong><span>Improve empty states · #11335</span></div><time>1h</time></article>
|
||||
</div>
|
||||
<button class="panel-footer">View full history <span>↗</span></button>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-screen" data-screen="map">
|
||||
<div class="map-layout">
|
||||
<aside class="attention-rail">
|
||||
<span class="eyebrow">Needs you</span>
|
||||
<button class="rail-alert"><span>!</span><div><strong>Token refresh behavior</strong><small>API Integrator · 1m</small></div></button>
|
||||
<button class="rail-alert"><span>!</span><div><strong>Rate limit strategy</strong><small>Backend Agent · 6m</small></div></button>
|
||||
</aside>
|
||||
<div class="agent-canvas">
|
||||
<div class="canvas-toolbar">
|
||||
<button class="icon-button">⌖</button><button class="icon-button">−</button><span>100%</span><button class="icon-button">+</button>
|
||||
<button class="button button-outline button-small"><svg class="icon"><use href="#i-layout"></use></svg>Auto layout</button>
|
||||
</div>
|
||||
<article class="mission-cluster">
|
||||
<header><svg class="icon"><use href="#i-folder"></use></svg><strong>Orca / Dashboard revamp</strong><span>albacore</span></header>
|
||||
<svg class="connectors" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d="M50 17V21M50 21H19V26M50 21V26M50 21H81V26"></path>
|
||||
<path d="M19 34V38M19 38H10V45M19 38H29V45M50 34V47"></path>
|
||||
</svg>
|
||||
<button class="map-node mission-node"><span class="node-glyph">◎</span><strong>Dashboard revamp</strong><small>Mission · 3 agents</small></button>
|
||||
<button class="map-node node-ui" data-map-agent="UI Builder"><span class="working-spinner"></span><strong>UI Builder</strong><small>Refactoring layout · 28m</small></button>
|
||||
<button class="map-node node-api is-selected" data-map-agent="API Integrator"><span class="working-spinner"></span><strong>API Integrator</strong><small>Wiring metrics · 19m</small></button>
|
||||
<button class="map-node node-e2e" data-map-agent="E2E Tester"><span class="working-spinner"></span><strong>E2E Tester</strong><small>Running flows · 11m</small></button>
|
||||
<button class="map-node mini-node node-child-a"><strong>Component Author</strong><small>Working</small></button>
|
||||
<button class="map-node mini-node node-child-b"><strong>Design Sync</strong><small>Idle</small></button>
|
||||
<button class="map-node output-node"><svg class="icon"><use href="#i-branch"></use></svg><strong>PR #11342</strong><small>Open</small></button>
|
||||
</article>
|
||||
</div>
|
||||
<aside class="agent-inspector">
|
||||
<div class="inspector-heading"><span class="agent-avatar">C</span><div><strong id="inspector-name">API Integrator</strong><span>Codex · gpt-5.6</span></div><button class="icon-button">×</button></div>
|
||||
<div class="status-pill"><span class="working-spinner"></span>Working</div>
|
||||
<dl class="inspector-facts">
|
||||
<div><dt>Current action</dt><dd id="inspector-action">Wiring metrics endpoints</dd></div>
|
||||
<div><dt>Elapsed</dt><dd>19m</dd></div>
|
||||
</dl>
|
||||
<section class="inspector-section">
|
||||
<span class="eyebrow">Latest activity</span>
|
||||
<div class="mini-timeline"><p><i></i><span>Started working</span><time>19m</time></p><p><i></i><span>Modified routes.ts</span><time>12m</time></p><p><i></i><span>Pushed metrics-endpoints</span><time>8m</time></p><p><i></i><span>Commented on PR #11342</span><time>3m</time></p></div>
|
||||
</section>
|
||||
<section class="inspector-section metadata-list">
|
||||
<p><span>Repo</span><strong>orca/dashboard</strong></p><p><span>Worktree</span><strong>wt/api-integrator</strong></p><p><span>Review</span><strong>PR #11342</strong></p><p><span>Subagents</span><strong>0</strong></p>
|
||||
</section>
|
||||
<div class="inspector-actions"><button class="button button-outline"><svg class="icon"><use href="#i-terminal"></use></svg>Open terminal</button><button class="button button-outline"><svg class="icon"><use href="#i-folder"></use></svg>Open worktree</button></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-screen" data-screen="history">
|
||||
<div class="history-header">
|
||||
<div><span class="eyebrow">Agent history</span><h2>What changed across your work</h2><p>Compare outcomes, duration, and review state without reopening every terminal.</p></div>
|
||||
<div class="history-metrics"><span><strong>38</strong> runs this week</span><span><strong>4h 12m</strong> active time</span><span><strong>81%</strong> completed</span></div>
|
||||
</div>
|
||||
<div class="history-toolbar">
|
||||
<div class="period-tabs"><button class="is-active">Today</button><button>7 days</button><button>30 days</button></div>
|
||||
<button class="button button-outline button-small"><svg class="icon"><use href="#i-filter"></use></svg>Project · All</button>
|
||||
<button class="button button-outline button-small">Outcome · All</button>
|
||||
</div>
|
||||
<div class="history-table-wrap">
|
||||
<table class="history-table">
|
||||
<thead><tr><th>Agent</th><th>Mission</th><th>Project / worktree</th><th>Started</th><th>Duration</th><th>Outcome</th><th>Output</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="agent-avatar">C</span><strong>E2E Tester</strong></td><td>Dashboard interaction flows</td><td><strong>Orca</strong><small>albacore</small></td><td>10:42 AM</td><td>18m</td><td><span class="outcome success"><svg class="icon"><use href="#i-check"></use></svg>Completed</span></td><td>58 tests passed</td><td>›</td></tr>
|
||||
<tr><td><span class="agent-avatar">C</span><strong>API Integrator</strong></td><td>Metrics endpoints</td><td><strong>Orca</strong><small>albacore</small></td><td>10:26 AM</td><td>31m</td><td><span class="outcome success"><svg class="icon"><use href="#i-check"></use></svg>Completed</span></td><td><span class="review-chip"><svg class="icon"><use href="#i-branch"></use></svg>PR #11342</span></td><td>›</td></tr>
|
||||
<tr><td><span class="agent-avatar">C</span><strong>Backend Agent</strong></td><td>Connection recovery</td><td><strong>Relay</strong><small>relay-reconnect</small></td><td>9:51 AM</td><td>42m</td><td><span class="outcome interrupted">Interrupted</span></td><td>3 files changed</td><td>›</td></tr>
|
||||
<tr><td><span class="agent-avatar">C</span><strong>UI Builder</strong></td><td>Empty states</td><td><strong>Orca</strong><small>dashboard-empty</small></td><td>9:18 AM</td><td>24m</td><td><span class="outcome success"><svg class="icon"><use href="#i-check"></use></svg>Completed</span></td><td><span class="review-chip"><svg class="icon"><use href="#i-branch"></use></svg>PR #11335</span></td><td>›</td></tr>
|
||||
<tr><td><span class="agent-avatar">C</span><strong>Docs Agent</strong></td><td>SSH troubleshooting guide</td><td><strong>Docs</strong><small>ssh-guide</small></td><td>Yesterday</td><td>16m</td><td><span class="outcome success"><svg class="icon"><use href="#i-check"></use></svg>Completed</span></td><td>2 docs updated</td><td>›</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="./agent-dashboard-concepts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
const body = document.body
|
||||
const tabs = [...document.querySelectorAll('[data-view]')]
|
||||
const screens = [...document.querySelectorAll('[data-screen]')]
|
||||
|
||||
function showView(view) {
|
||||
tabs.forEach((tab) => tab.classList.toggle('is-active', tab.dataset.view === view))
|
||||
screens.forEach((screen) => screen.classList.toggle('is-active', screen.dataset.screen === view))
|
||||
window.location.hash = view
|
||||
}
|
||||
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => showView(tab.dataset.view)))
|
||||
|
||||
document.querySelector('#theme-toggle')?.addEventListener('click', () => {
|
||||
body.classList.toggle('dark')
|
||||
})
|
||||
|
||||
document.querySelectorAll('.agent-row').forEach((row) => {
|
||||
row.addEventListener('click', () => {
|
||||
document.querySelectorAll('.agent-row').forEach((candidate) => candidate.classList.remove('is-selected'))
|
||||
row.classList.add('is-selected')
|
||||
})
|
||||
})
|
||||
|
||||
const mapAgentDetails = {
|
||||
'UI Builder': 'Refactoring dashboard information hierarchy',
|
||||
'API Integrator': 'Wiring metrics endpoints',
|
||||
'E2E Tester': 'Running dashboard interaction flows'
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-map-agent]').forEach((node) => {
|
||||
node.addEventListener('click', () => {
|
||||
document.querySelectorAll('[data-map-agent]').forEach((candidate) => candidate.classList.remove('is-selected'))
|
||||
node.classList.add('is-selected')
|
||||
const name = node.dataset.mapAgent
|
||||
document.querySelector('#inspector-name').textContent = name
|
||||
document.querySelector('#inspector-action').textContent = mapAgentDetails[name]
|
||||
})
|
||||
})
|
||||
|
||||
const initialView = window.location.hash.slice(1)
|
||||
if (screens.some((screen) => screen.dataset.screen === initialView)) {
|
||||
showView(initialView)
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const view = window.location.hash.slice(1)
|
||||
if (screens.some((screen) => screen.dataset.screen === view)) {
|
||||
showView(view)
|
||||
}
|
||||
})
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,24 @@
|
||||
# Orca Agent Fleet Concepts
|
||||
|
||||
Six ways to operate a fleet too large for one-card-per-agent UI:
|
||||
|
||||
- **Constellation** — spatial worktree clusters with agent lineage on demand.
|
||||
- **Tactical Radar** — urgency radiates from the center; distance means intervention priority.
|
||||
- **Fleet Matrix** — compact, sortable operations view for all worktrees and agents.
|
||||
- **Timeline Lanes** — exposes long-running agents, quiet terminals, and time outliers.
|
||||
- **Orchestrator HUD** — a worktree-less global command layer based on PR #1896.
|
||||
- **Mission Treemap** — area communicates agent count while edge state communicates urgency.
|
||||
|
||||
## Recommended direction
|
||||
|
||||
Combine three layers with semantic zoom:
|
||||
|
||||
1. **Orchestrator HUD** stays globally summonable and collapses 168 agents into decisions.
|
||||
2. **Radar** is the default ambient overview for attention, activity, and host health.
|
||||
3. **Matrix** is the dense fallback for exact inspection, filtering, and bulk action.
|
||||
|
||||
Drill-down should follow fleet → worktree → lineage → terminal. The interface should never
|
||||
render every agent as a full card or labeled graph node at once.
|
||||
|
||||
The mock data reflects the live fleet observed while designing: 168 visible agents, 34
|
||||
working, 71 unread worktrees, and 332 terminals.
|
||||
@@ -0,0 +1,259 @@
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url("../src/renderer/src/assets/fonts/Geist-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #fff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #fff;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--border: #e5e5e5;
|
||||
--ring: #a1a1a1;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fafafa;
|
||||
--attention: #f59e0b;
|
||||
--success: #15803d;
|
||||
--danger: #e40014;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #171717;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #404040;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--ring: #737373;
|
||||
--primary: #e5e5e5;
|
||||
--primary-foreground: #171717;
|
||||
--success: #86efac;
|
||||
--danger: #ff6568;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; overflow: hidden; background: var(--background); color: var(--foreground); font: 14px/1.45 Geist, sans-serif; letter-spacing: .01em; }
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
.svg-defs { position: absolute; width: 0; height: 0; }
|
||||
.icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.fleet-shell { min-width: 1280px; height: 100vh; min-height: 780px; overflow: hidden; background: var(--background); }
|
||||
.fleet-header { height: 62px; display: flex; align-items: center; gap: 14px; padding: 0 18px; border-bottom: 1px solid var(--border); }
|
||||
.orca-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: var(--primary); color: var(--primary-foreground); font-weight: 800; }
|
||||
.fleet-title { display: flex; flex-direction: column; min-width: 86px; }
|
||||
.fleet-title h1 { margin: 0; font-size: 17px; line-height: 1.1; }
|
||||
.fleet-title span { color: var(--muted-foreground); font-size: 10px; }
|
||||
.total-pill { display: flex; align-items: baseline; gap: 5px; padding: 5px 9px; border-radius: 7px; background: var(--muted); }
|
||||
.total-pill strong { font-size: 15px; }
|
||||
.total-pill span { color: var(--muted-foreground); font-size: 10px; text-transform: uppercase; }
|
||||
.concept-tabs { display: flex; gap: 2px; margin-left: 8px; padding: 3px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.concept-tab { height: 29px; padding: 0 11px; border: 0; border-radius: 6px; background: transparent; color: var(--muted-foreground); font-size: 11px; cursor: pointer; }
|
||||
.concept-tab:hover, .concept-tab.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.concept-tab.is-active { box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.header-tools { display: flex; align-items: center; gap: 7px; margin-left: auto; }
|
||||
.search-box { width: 240px; height: 32px; display: flex; align-items: center; gap: 7px; padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--muted) 55%, transparent); color: var(--muted-foreground); }
|
||||
.search-box input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--foreground); }
|
||||
kbd { padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; background: var(--muted); color: var(--muted-foreground); font-size: 9px; white-space: nowrap; }
|
||||
.icon-button, .button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); cursor: pointer; }
|
||||
.icon-button { width: 32px; height: 32px; }
|
||||
.button { min-height: 31px; padding: 0 10px; font-size: 11px; }
|
||||
.button:hover, .icon-button:hover { background: var(--muted); }
|
||||
.button.primary { border-color: var(--primary); background: var(--primary); color: var(--primary-foreground); }
|
||||
.concept-screen { display: none; height: calc(100vh - 62px); min-height: 718px; }
|
||||
.concept-screen.is-active { display: grid; animation: reveal .18s ease-out; }
|
||||
.eyebrow, .panel-kicker { color: var(--muted-foreground); font-size: 10px; font-weight: 650; letter-spacing: .055em; text-transform: uppercase; }
|
||||
.attention-text { color: var(--attention) !important; }
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(2px); } }
|
||||
|
||||
/* Constellation */
|
||||
[data-screen="constellation"] { grid-template-columns: 230px minmax(720px, 1fr) 310px; }
|
||||
.attention-dock, .context-panel { background: color-mix(in srgb, var(--card) 68%, var(--background)); }
|
||||
.attention-dock { padding: 16px 12px; border-right: 1px solid var(--border); }
|
||||
.dock-heading { display: flex; align-items: center; justify-content: space-between; padding: 0 5px 11px; }
|
||||
.dock-heading strong { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: color-mix(in srgb, var(--attention) 12%, transparent); color: var(--attention); font-size: 10px; }
|
||||
.attention-ticket { width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 9px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent; text-align: left; cursor: pointer; }
|
||||
.attention-ticket:hover, .attention-ticket.is-hot { border-color: color-mix(in srgb, var(--attention) 25%, transparent); background: color-mix(in srgb, var(--attention) 7%, transparent); }
|
||||
.ticket-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--attention); box-shadow: 0 0 8px color-mix(in srgb, var(--attention) 65%, transparent); }
|
||||
.attention-ticket div { min-width: 0; display: flex; flex-direction: column; }
|
||||
.attention-ticket strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attention-ticket small, .attention-ticket time { color: var(--muted-foreground); font-size: 9px; }
|
||||
.dock-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-top: 18px; padding-top: 13px; border-top: 1px solid var(--border); }
|
||||
.dock-stats p { display: flex; flex-direction: column; margin: 0; padding: 6px; border-radius: 6px; background: color-mix(in srgb, var(--muted) 50%, transparent); }
|
||||
.dock-stats span { color: var(--muted-foreground); font-size: 8px; text-transform: uppercase; }
|
||||
.dock-stats strong { font-size: 13px; }
|
||||
.constellation-canvas { position: relative; overflow: hidden; background-image: radial-gradient(color-mix(in srgb, var(--muted-foreground) 20%, transparent) .65px, transparent .65px); background-size: 18px 18px; }
|
||||
.canvas-topline { position: absolute; z-index: 6; top: 12px; left: 14px; right: 14px; display: flex; align-items: center; justify-content: space-between; color: var(--muted-foreground); font-size: 10px; }
|
||||
.canvas-topline > span { display: flex; align-items: center; gap: 6px; }
|
||||
.canvas-topline button { padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); color: var(--muted-foreground); font-size: 9px; }
|
||||
.fleet-edges { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.fleet-edges path { fill: none; stroke: color-mix(in srgb, var(--muted-foreground) 35%, transparent); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||
.fleet-core { position: absolute; z-index: 3; top: 50%; left: 50%; width: 150px; height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 1px solid color-mix(in srgb, var(--ring) 60%, transparent); border-radius: 50%; background: radial-gradient(circle, var(--card) 0 43%, color-mix(in srgb, var(--card) 76%, transparent) 44% 55%, transparent 56%); transform: translate(-50%, -50%); cursor: pointer; }
|
||||
.fleet-core::before, .fleet-core::after { position: absolute; content: ""; border: 1px solid color-mix(in srgb, var(--ring) 24%, transparent); border-radius: 50%; }
|
||||
.fleet-core::before { inset: -18px; }.fleet-core::after { inset: -36px; }
|
||||
.fleet-core strong { font-size: 12px; letter-spacing: .08em; }.fleet-core small { width: 104px; color: var(--muted-foreground); font-size: 8px; }
|
||||
.constellation-cluster { position: absolute; z-index: 4; width: 205px; overflow: hidden; border: 1px solid var(--border); border-radius: 9px; background: color-mix(in srgb, var(--card) 90%, transparent); box-shadow: 0 8px 24px rgb(0 0 0 / .12); }
|
||||
.constellation-cluster.selected { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
.constellation-cluster header { display: flex; align-items: flex-start; gap: 7px; padding: 9px 10px 5px; }
|
||||
.constellation-cluster header > div { min-width: 0; display: flex; flex: 1; flex-direction: column; }
|
||||
.constellation-cluster header strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.constellation-cluster header span, .constellation-cluster footer { color: var(--muted-foreground); font-size: 8px; }
|
||||
.constellation-cluster header b { padding: 2px 5px; border-radius: 999px; background: color-mix(in srgb, var(--attention) 13%, transparent); color: var(--attention); font-size: 8px; }
|
||||
.node-cloud, .tiny-agents { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.node-cloud { min-height: 38px; padding: 5px 10px 8px; }
|
||||
.node-cloud i, .tiny-agents i { display: block; border-radius: 50%; background: var(--muted-foreground); opacity: .35; }
|
||||
.node-cloud i { width: 8px; height: 8px; }.tiny-agents i { width: 6px; height: 6px; }
|
||||
.node-cloud i.is-working, .tiny-agents i.is-working { border: 1px solid var(--muted-foreground); background: transparent; opacity: .9; animation: pulse-ring 1.8s ease-in-out infinite; }
|
||||
.node-cloud i.is-urgent, .tiny-agents i.is-urgent { background: var(--attention); opacity: 1; box-shadow: 0 0 6px color-mix(in srgb, var(--attention) 55%, transparent); }
|
||||
@keyframes pulse-ring { 50% { opacity: .35; } }
|
||||
.constellation-cluster footer { display: flex; justify-content: space-between; padding: 5px 10px; border-top: 1px solid var(--border); }
|
||||
.cluster-review { top: 13%; left: 6%; }.cluster-mobile { top: 11%; right: 5%; }.cluster-pr { top: 49%; right: 2%; }
|
||||
.cluster-trust { bottom: 10%; left: 7%; }.cluster-resume { right: 28%; bottom: 5%; }
|
||||
.canvas-controls { position: absolute; z-index: 6; bottom: 14px; left: 14px; display: flex; align-items: center; gap: 4px; padding: 4px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); }
|
||||
.canvas-controls button { height: 27px; padding: 0 8px; border: 0; border-radius: 5px; background: transparent; font-size: 9px; }
|
||||
.canvas-controls button:hover { background: var(--muted); }.canvas-controls span { padding: 0 5px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.canvas-minimap { position: absolute; right: 14px; bottom: 14px; width: 105px; height: 68px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); opacity: .85; }
|
||||
.canvas-minimap::before { position: absolute; inset: 10px 14px; content: ""; border: 1px solid var(--ring); }.canvas-minimap i, .canvas-minimap span, .canvas-minimap b { position: absolute; width: 5px; height: 5px; border-radius: 50%; background: var(--muted-foreground); }
|
||||
.canvas-minimap i { top: 18px; left: 25px; }.canvas-minimap span { top: 34px; right: 28px; }.canvas-minimap b { bottom: 14px; left: 48px; }
|
||||
.context-panel { display: flex; flex-direction: column; gap: 14px; padding: 18px; border-left: 1px solid var(--border); overflow: auto; }
|
||||
.context-panel h2 { margin: -5px 0 0; font-size: 15px; }
|
||||
.panel-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
|
||||
.panel-summary span { display: flex; flex-direction: column; padding: 8px; border-radius: 6px; background: var(--muted); color: var(--muted-foreground); font-size: 8px; text-transform: uppercase; }
|
||||
.panel-summary strong { color: var(--foreground); font-size: 14px; }
|
||||
.context-panel section { padding-top: 13px; border-top: 1px solid var(--border); }
|
||||
.lineage-tree p { position: relative; display: flex; align-items: center; gap: 7px; margin: 5px 0; color: var(--muted-foreground); font-size: 10px; }
|
||||
.lineage-tree p::before { position: absolute; top: -5px; bottom: 50%; left: -10px; width: 7px; border-bottom: 1px solid var(--border); border-left: 1px solid var(--border); content: ""; }
|
||||
.lineage-tree .root::before { display: none; }.lineage-tree .child { margin-left: 14px; }.lineage-tree .grandchild { margin-left: 30px; }
|
||||
.lineage-tree i, .legend-list i, .signal-stream i, .treemap-legend i { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: var(--muted-foreground); opacity: .45; }
|
||||
.lineage-tree i.working, .legend-list i.active, .signal-stream i.active, .treemap-legend i.active { border: 1px solid var(--muted-foreground); background: transparent; opacity: 1; }
|
||||
.signal-copy { margin: 8px 0 3px; font-size: 11px; }.context-panel section time { color: var(--muted-foreground); font-size: 9px; }
|
||||
.panel-actions { display: grid; gap: 6px; margin-top: auto; }
|
||||
|
||||
/* Radar */
|
||||
[data-screen="radar"] { grid-template-columns: 250px minmax(700px, 1fr) 290px; }
|
||||
.radar-legend, .signal-stream { background: color-mix(in srgb, var(--card) 68%, var(--background)); }
|
||||
.radar-legend { padding: 20px 16px; border-right: 1px solid var(--border); }
|
||||
.radar-legend h2 { margin: 5px 0 2px; font-size: 18px; }.radar-legend > p { margin: 0 0 20px; color: var(--muted-foreground); font-size: 10px; }
|
||||
.legend-list { padding: 10px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.legend-list p { display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 8px; margin: 10px 2px; color: var(--muted-foreground); font-size: 10px; }
|
||||
.legend-list i.urgent, .signal-stream i.urgent, .treemap-legend i.urgent { background: var(--attention); opacity: 1; box-shadow: 0 0 6px color-mix(in srgb, var(--attention) 60%, transparent); }
|
||||
.legend-list i.done, .signal-stream i.done { background: var(--success); opacity: .85; }
|
||||
.legend-list strong { color: var(--foreground); }.radar-filters { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: 13px; }
|
||||
.radar-filters button { padding: 6px; border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--muted-foreground); font-size: 9px; }
|
||||
.radar-filters button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.radar-note { display: flex; gap: 8px; margin-top: 20px; padding: 10px; border: 1px solid color-mix(in srgb, var(--attention) 20%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--attention) 6%, transparent); color: var(--attention); }
|
||||
.radar-note span { display: flex; flex-direction: column; font-size: 9px; }.radar-note strong { color: var(--foreground); font-size: 10px; }
|
||||
.radar-stage { position: relative; overflow: hidden; background: radial-gradient(circle, color-mix(in srgb, var(--muted) 26%, transparent) 0, transparent 62%); }
|
||||
.radar-ring { position: absolute; top: 50%; left: 50%; border: 1px solid color-mix(in srgb, var(--muted-foreground) 26%, transparent); border-radius: 50%; transform: translate(-50%, -50%); }
|
||||
.radar-ring span { position: absolute; top: -8px; left: 50%; padding: 0 6px; background: var(--background); color: var(--muted-foreground); font-size: 7px; letter-spacing: .08em; transform: translateX(-50%); }
|
||||
.ring-one { width: 220px; height: 220px; border-color: color-mix(in srgb, var(--attention) 34%, transparent); }.ring-two { width: 390px; height: 390px; }.ring-three { width: 570px; height: 570px; }.ring-four { width: 750px; height: 750px; }
|
||||
.radar-crosshair { position: absolute; top: 50%; left: 50%; background: color-mix(in srgb, var(--muted-foreground) 16%, transparent); transform: translate(-50%, -50%); }
|
||||
.radar-crosshair.horizontal { width: 92%; height: 1px; }.radar-crosshair.vertical { width: 1px; height: 92%; }
|
||||
.radar-sweep { position: absolute; z-index: 1; top: 50%; left: 50%; width: 370px; height: 370px; border-radius: 0 100% 0 0; background: conic-gradient(from 0deg, color-mix(in srgb, var(--ring) 14%, transparent), transparent 32deg); transform-origin: 0 0; animation: sweep 9s linear infinite; }
|
||||
@keyframes sweep { to { transform: rotate(360deg); } }
|
||||
.radar-core { position: absolute; z-index: 5; top: 50%; left: 50%; width: 112px; height: 112px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--ring); border-radius: 50%; background: var(--card); transform: translate(-50%, -50%); }
|
||||
.radar-core strong { font-size: 24px; }.radar-core span { color: var(--muted-foreground); font-size: 8px; text-transform: uppercase; }
|
||||
#radar-blips i { position: absolute; z-index: 3; width: 4px; height: 4px; border-radius: 50%; background: var(--muted-foreground); opacity: .4; transform: translate(-50%, -50%); }
|
||||
#radar-blips i.active { width: 6px; height: 6px; border: 1px solid var(--muted-foreground); background: transparent; opacity: .8; }
|
||||
#radar-blips i.urgent { width: 7px; height: 7px; background: var(--attention); opacity: 1; box-shadow: 0 0 7px color-mix(in srgb, var(--attention) 80%, transparent); }
|
||||
#radar-blips i.done { background: var(--success); opacity: .35; }
|
||||
.radar-label { position: absolute; z-index: 6; display: grid; grid-template-columns: 7px auto; gap: 0 6px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: color-mix(in srgb, var(--card) 90%, transparent); text-align: left; }
|
||||
.radar-label i { grid-row: span 2; width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: var(--attention); box-shadow: 0 0 6px var(--attention); }
|
||||
.radar-label strong { font-size: 9px; }.radar-label span { color: var(--muted-foreground); font-size: 8px; }
|
||||
.label-a { top: 36%; left: 42%; }.label-b { top: 56%; left: 30%; }.label-c { top: 27%; right: 19%; }
|
||||
.signal-stream { padding: 18px 16px; border-left: 1px solid var(--border); }
|
||||
.stream-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 10px; }
|
||||
.stream-heading button { border: 0; background: transparent; color: var(--muted-foreground); font-size: 9px; }
|
||||
.signal-stream article { min-height: 62px; display: grid; grid-template-columns: 8px 1fr auto; align-items: center; gap: 9px; border-top: 1px solid var(--border); }
|
||||
.signal-stream article div { display: flex; flex-direction: column; }.signal-stream article strong { font-size: 10px; }.signal-stream article span, .signal-stream time { color: var(--muted-foreground); font-size: 8px; }
|
||||
|
||||
/* Matrix */
|
||||
[data-screen="matrix"] { grid-template-columns: 1fr; }
|
||||
.matrix-shell { display: flex; min-height: 0; flex-direction: column; padding: 18px 22px; }
|
||||
.matrix-toolbar { display: flex; align-items: center; gap: 22px; padding-bottom: 13px; }
|
||||
.matrix-toolbar h2 { margin: 3px 0 0; font-size: 17px; }.matrix-metrics { display: flex; gap: 16px; margin-left: 15px; }
|
||||
.matrix-metrics span { display: flex; align-items: center; gap: 5px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.matrix-metrics i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted-foreground); opacity: .45; }.matrix-metrics i.urgent { background: var(--attention); opacity: 1; }.matrix-metrics i.active { border: 1px solid var(--muted-foreground); background: transparent; opacity: 1; }
|
||||
.matrix-metrics strong { color: var(--foreground); font-size: 12px; }.matrix-metrics .icon { width: 12px; height: 12px; }
|
||||
.matrix-actions { display: flex; gap: 6px; margin-left: auto; }
|
||||
.matrix-grid { min-height: 0; flex: 1; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--card) 82%, transparent); }
|
||||
.matrix-row { width: 100%; min-height: 39px; display: grid; grid-template-columns: minmax(250px, 1.5fr) minmax(230px, 1.2fr) 90px 80px 70px 85px 80px; align-items: center; border: 0; border-bottom: 1px solid var(--border); background: transparent; text-align: left; }
|
||||
.matrix-row > span { padding: 0 12px; color: var(--muted-foreground); font-size: 10px; }
|
||||
.matrix-head { min-height: 34px; background: var(--muted); }.matrix-head > span { font-size: 8px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.matrix-row:not(.matrix-head):hover, .matrix-row.has-attention { background: color-mix(in srgb, var(--accent) 25%, transparent); }.matrix-row.has-attention { box-shadow: inset 2px 0 var(--attention); }
|
||||
.matrix-row > span:first-child { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; align-items: center; gap: 7px; color: var(--foreground); }
|
||||
.matrix-row > span:first-child .icon { width: 12px; height: 12px; color: var(--muted-foreground); }.matrix-row > span:first-child strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.matrix-row > span:first-child small { color: var(--muted-foreground); font-size: 8px; }
|
||||
.fabric { display: flex; align-items: center; gap: 4px; }.fabric i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted-foreground); opacity: .35; }.fabric i.active { border: 1px solid var(--muted-foreground); background: transparent; opacity: .85; }.fabric i.urgent { background: var(--attention); opacity: 1; }.fabric i.done { background: var(--success); opacity: .3; }
|
||||
.matrix-footer { display: flex; justify-content: space-between; padding: 9px 4px 0; color: var(--muted-foreground); font-size: 9px; }
|
||||
|
||||
/* Timeline */
|
||||
[data-screen="timeline"] { grid-template-columns: minmax(850px, 1fr) 280px; }
|
||||
.timeline-shell { position: relative; min-width: 0; padding: 18px 18px 18px 24px; }
|
||||
.timeline-heading { height: 58px; display: flex; align-items: center; gap: 25px; }
|
||||
.timeline-heading h2 { margin: 3px 0 0; font-size: 17px; }.time-window { display: flex; margin-left: auto; padding: 3px; border: 1px solid var(--border); border-radius: 7px; }
|
||||
.time-window button { padding: 5px 10px; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); font-size: 9px; }.time-window button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.timeline-alert { display: grid; grid-template-columns: 16px auto; gap: 0 7px; padding: 6px 9px; border: 1px solid color-mix(in srgb, var(--attention) 24%, transparent); border-radius: 7px; background: color-mix(in srgb, var(--attention) 6%, transparent); }
|
||||
.timeline-alert .icon { grid-row: span 2; color: var(--attention); }.timeline-alert strong { font-size: 9px; }.timeline-alert span { color: var(--muted-foreground); font-size: 8px; }
|
||||
.time-axis { height: 30px; display: grid; grid-template-columns: repeat(5, 1fr); margin-left: 230px; align-items: end; border-bottom: 1px solid var(--border); color: var(--muted-foreground); font-size: 8px; }
|
||||
.time-axis span, .time-axis strong { padding-bottom: 7px; border-left: 1px solid var(--border); text-align: center; }.time-axis strong { color: var(--attention); }
|
||||
.timeline-grid { border-left: 1px solid var(--border); border-right: 1px solid var(--border); }
|
||||
.timeline-row { min-height: 51px; display: grid; grid-template-columns: 230px 1fr 60px; align-items: center; border-bottom: 1px solid var(--border); }
|
||||
.timeline-row:hover, .timeline-row.is-selected { background: color-mix(in srgb, var(--accent) 25%, transparent); }.timeline-row.is-selected { box-shadow: inset 2px 0 var(--attention); }
|
||||
.lane-name { display: flex; flex-direction: column; padding: 0 11px; min-width: 0; }.lane-name strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.lane-name small, .lane-count { color: var(--muted-foreground); font-size: 8px; }
|
||||
.lane-track { position: relative; height: 100%; background-image: linear-gradient(to right, var(--border) 1px, transparent 1px); background-size: 25% 100%; }
|
||||
.time-segment { position: absolute; top: 50%; height: 7px; border-radius: 4px; background: var(--muted-foreground); opacity: .3; transform: translateY(-50%); }.time-segment.active { height: 9px; border: 1px solid var(--muted-foreground); background: transparent; opacity: .85; }.time-segment.urgent { height: 9px; background: var(--attention); opacity: .8; box-shadow: 0 0 8px color-mix(in srgb, var(--attention) 55%, transparent); }.time-segment.done { background: var(--success); opacity: .28; }
|
||||
.outlier-panel { display: flex; flex-direction: column; gap: 14px; padding: 18px; border-left: 1px solid var(--border); background: color-mix(in srgb, var(--card) 68%, var(--background)); }
|
||||
.outlier-panel h3 { margin: -5px 0 0; font-size: 14px; }.outlier-agent { display: flex; align-items: center; gap: 9px; padding: 9px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.agent-avatar { display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--border); border-radius: 6px; background: var(--muted); font-size: 10px; font-weight: 700; }
|
||||
.outlier-agent div { display: flex; flex-direction: column; }.outlier-agent strong { font-size: 10px; }.outlier-agent span { color: var(--muted-foreground); font-size: 8px; }
|
||||
.outlier-panel dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin: 0; }.outlier-panel dl div { padding: 7px; border-radius: 6px; background: var(--muted); }.outlier-panel dt { color: var(--muted-foreground); font-size: 8px; text-transform: uppercase; }.outlier-panel dd { margin: 2px 0 0; font-size: 11px; }
|
||||
.outlier-panel p { color: var(--muted-foreground); font-size: 10px; }.outlier-panel .button { margin-top: auto; }
|
||||
|
||||
/* Orchestrator HUD */
|
||||
[data-screen="orchestrator"] { position: relative; grid-template-columns: 1fr; overflow: hidden; background: var(--background); }
|
||||
.workspace-backdrop { position: absolute; inset: 0; display: grid; grid-template-columns: 230px 1fr; filter: blur(.2px); opacity: .42; }
|
||||
.workspace-backdrop aside { display: flex; flex-direction: column; gap: 12px; padding: 20px 14px; border-right: 1px solid var(--border); background: var(--card); }.workspace-backdrop aside span { height: 44px; border-radius: 7px; background: var(--muted); }.fake-orca { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 7px; background: var(--primary); color: var(--primary-foreground); }
|
||||
.fake-editor { padding: 24px; }.fake-tabs { display: flex; gap: 6px; }.fake-tabs b { width: 100px; height: 27px; border-radius: 6px 6px 0 0; background: var(--muted); }
|
||||
.code-lines { width: 80%; height: 540px; background: repeating-linear-gradient(to bottom, transparent 0 18px, color-mix(in srgb, var(--muted-foreground) 18%, transparent) 19px 20px); mask: linear-gradient(90deg, #000 0 72%, transparent 72%); }
|
||||
.orchestrator-hud { position: absolute; z-index: 4; top: 58px; left: 50%; width: min(970px, calc(100% - 160px)); height: 650px; display: flex; flex-direction: column; overflow: hidden; border: 1px solid color-mix(in srgb, var(--ring) 72%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--card) 96%, transparent); box-shadow: 0 10px 24px rgb(0 0 0 / .18); transform: translateX(-50%); }
|
||||
.orchestrator-hud > header { height: 49px; display: flex; align-items: center; gap: 8px; padding: 0 11px; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--muted) 55%, var(--card)); }.drag-grip { color: var(--muted-foreground); }
|
||||
.hud-identity { display: flex; align-items: center; gap: 7px; }.orch-badge { padding: 2px 5px; border-radius: 4px; background: var(--primary); color: var(--primary-foreground); font-size: 8px; font-weight: 700; letter-spacing: .06em; }.hud-identity strong { font-size: 11px; }.hud-identity small { color: var(--muted-foreground); font-size: 9px; }
|
||||
.hud-health { display: flex; align-items: center; gap: 5px; margin-left: auto; color: var(--muted-foreground); font-size: 9px; }.hud-health i { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 6px color-mix(in srgb, var(--success) 50%, transparent); }.orchestrator-hud > header button { width: 25px; height: 25px; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); }
|
||||
.hud-body { min-height: 0; flex: 1; padding: 20px 24px; overflow: auto; }.hud-command { height: 46px; display: flex; align-items: center; gap: 10px; padding: 0 13px; border: 1px solid var(--ring); border-radius: 8px; background: var(--background); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 15%, transparent); }.hud-command > span { color: var(--success); font: 15px var(--font-mono, monospace); }.hud-command strong { flex: 1; font-size: 12px; }
|
||||
.hud-answer { margin-top: 20px; }.answer-heading { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 9px; padding-bottom: 11px; }.answer-heading > .icon { width: 26px; height: 26px; padding: 5px; border-radius: 7px; background: var(--muted); }.answer-heading > div { display: flex; flex-direction: column; }.answer-heading strong { font-size: 12px; }.answer-heading span, .answer-heading time { color: var(--muted-foreground); font-size: 9px; }
|
||||
.decision-card { min-height: 77px; display: grid; grid-template-columns: 32px minmax(0, 1fr) 65px 70px; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-bottom: 0; background: color-mix(in srgb, var(--background) 50%, transparent); }.decision-card:first-of-type { border-radius: 8px 8px 0 0; }.decision-card:last-of-type { border-bottom: 1px solid var(--border); border-radius: 0 0 8px 8px; }.decision-card.urgent { box-shadow: inset 2px 0 var(--attention); background: color-mix(in srgb, var(--attention) 5%, transparent); }
|
||||
.decision-index { color: var(--muted-foreground); font: 10px var(--font-mono, monospace); }.decision-card > div:nth-child(2) { display: flex; min-width: 0; flex-direction: column; }.decision-card strong { font-size: 10px; }.decision-card span, .decision-card p { color: var(--muted-foreground); font-size: 8px; }.decision-card p { margin: 3px 0 0; }.decision-card b { color: var(--muted-foreground); font-size: 9px; font-weight: 500; }.decision-card button { height: 28px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); font-size: 9px; }
|
||||
.suggested-commands { display: flex; align-items: center; gap: 5px; margin-top: 16px; }.suggested-commands span { margin-right: 2px; color: var(--muted-foreground); font-size: 9px; }.suggested-commands button { padding: 5px 8px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--muted-foreground); font-size: 8px; }
|
||||
.orchestrator-hud > footer { min-height: 40px; display: flex; align-items: center; gap: 16px; padding: 0 12px; border-top: 1px solid var(--border); color: var(--muted-foreground); font-size: 8px; }.orchestrator-hud > footer i { flex: 1; }.orchestrator-hud > footer strong { color: var(--foreground); font-weight: 500; }
|
||||
.back-trail { position: absolute; z-index: 3; top: 14px; left: 250px; display: flex; align-items: center; gap: 7px; padding: 4px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); color: var(--muted-foreground); font-size: 9px; }.back-trail button { padding: 4px 7px; border: 0; border-radius: 5px; background: var(--primary); color: var(--primary-foreground); font-size: 9px; }
|
||||
|
||||
/* Treemap */
|
||||
[data-screen="treemap"] { grid-template-columns: 1fr; }
|
||||
.treemap-shell { display: flex; min-height: 0; flex-direction: column; padding: 16px 20px 14px; }
|
||||
.treemap-heading { display: flex; align-items: center; gap: 20px; padding-bottom: 12px; }.treemap-heading h2 { margin: 3px 0 0; font-size: 16px; }.treemap-heading > .button { margin-left: auto; }
|
||||
.treemap-legend { display: flex; gap: 14px; margin-left: 22px; }.treemap-legend span { display: flex; align-items: center; gap: 5px; color: var(--muted-foreground); font-size: 8px; }
|
||||
.treemap { min-height: 0; flex: 1; display: grid; grid-template-columns: 2.2fr 1.05fr; grid-template-rows: 1.25fr .75fr; gap: 8px; }
|
||||
.project-zone { position: relative; display: grid; gap: 5px; padding: 39px 7px 7px; border: 1px solid var(--border); border-radius: 11px; background: color-mix(in srgb, var(--card) 67%, transparent); }.project-zone > header { position: absolute; top: 0; left: 0; right: 0; height: 34px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; border-bottom: 1px solid var(--border); }.project-zone > header div { display: flex; align-items: center; gap: 6px; }.project-zone > header strong { font-size: 10px; letter-spacing: .04em; }.project-zone > header span, .project-zone > header b { color: var(--muted-foreground); font-size: 8px; }.project-zone > header b { color: var(--attention); font-weight: 500; }
|
||||
.zone-orca { grid-row: 1 / 3; grid-template-columns: 1.25fr 1fr .75fr; grid-template-rows: 1.2fr .8fr 1fr; }.zone-mobile { grid-template-columns: 1.2fr .8fr; grid-template-rows: 1.2fr .8fr; }.zone-relay { grid-template-columns: 1.2fr .8fr; grid-template-rows: 1fr; }
|
||||
.tree-tile { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: flex-start; justify-content: flex-start; overflow: hidden; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); text-align: left; cursor: pointer; }.tree-tile:hover { border-color: var(--ring); background: color-mix(in srgb, var(--card) 78%, var(--accent)); }.tree-tile.urgent { border-color: color-mix(in srgb, var(--attention) 38%, transparent); box-shadow: inset 0 0 20px color-mix(in srgb, var(--attention) 5%, transparent); }
|
||||
.tree-tile strong { max-width: 100%; overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }.tree-tile > span { color: var(--muted-foreground); font-size: 8px; }.tree-tile .tiny-agents { margin-top: auto; padding-top: 8px; }
|
||||
.tile-8783 { grid-row: 1 / 3; }.tile-9190 { grid-column: 2 / 4; }.tile-11464 { grid-column: 2; }.tile-trust { grid-column: 3; }.tile-resume { grid-column: 1 / 2; }.tile-other { grid-column: 2 / 4; }
|
||||
.aggregate-bars { display: flex; align-items: end; gap: 3px; width: 90%; height: 20px; margin-top: auto; }.aggregate-bars i { width: 18%; border-radius: 2px 2px 0 0; background: var(--muted-foreground); opacity: .25; }.aggregate-bars i:nth-child(1) { height: 30%; }.aggregate-bars i:nth-child(2) { height: 80%; }.aggregate-bars i:nth-child(3) { height: 55%; }.aggregate-bars i:nth-child(4) { height: 40%; }
|
||||
.tile-mobile-main { grid-row: 1 / 3; }.tile-mobile-more { grid-column: 2; }.treemap-inspector { display: flex; align-items: center; gap: 15px; padding: 10px 3px 0; color: var(--muted-foreground); font-size: 9px; }.treemap-inspector strong { color: var(--foreground); font-weight: 500; }.treemap-inspector button { display: inline-flex; align-items: center; gap: 5px; margin-left: auto; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); font-size: 9px; }
|
||||
|
||||
@media (max-width: 1420px) {
|
||||
.concept-tab { padding: 0 8px; }
|
||||
.search-box { width: 190px; }
|
||||
[data-screen="constellation"] { grid-template-columns: 210px minmax(650px, 1fr) 285px; }
|
||||
[data-screen="radar"] { grid-template-columns: 225px minmax(650px, 1fr) 260px; }
|
||||
.matrix-row { grid-template-columns: minmax(220px, 1.4fr) minmax(190px, 1fr) 75px 70px 65px 75px 70px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Agent Fleet Concepts</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./agent-fleet-concepts.css" />
|
||||
</head>
|
||||
<body class="dark">
|
||||
<svg class="svg-defs" aria-hidden="true">
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M4 5h16M7 12h10M10 19h4"></path></symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7h6l2 2h10v10H3z"></path></symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.9"></path></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></symbol>
|
||||
<symbol id="i-branch" viewBox="0 0 24 24"><circle cx="6" cy="5" r="2"></circle><circle cx="18" cy="6" r="2"></circle><circle cx="6" cy="19" r="2"></circle><path d="M6 7v10M8 9c5 0 4-3 8-3"></path></symbol>
|
||||
<symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"></path></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"></circle><path d="m8 12 3 3 5-6"></path></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><path d="m4 6 5 5-5 5M12 18h8"></path></symbol>
|
||||
<symbol id="i-layout" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 9v12"></path></symbol>
|
||||
<symbol id="i-focus" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"></path><circle cx="12" cy="12" r="3"></circle></symbol>
|
||||
<symbol id="i-command" viewBox="0 0 24 24"><path d="M9 6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3z"></path></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 3 2 21h20zM12 9v5M12 18h.01"></path></symbol>
|
||||
<symbol id="i-zap" viewBox="0 0 24 24"><path d="m13 2-9 12h8l-1 8 9-12h-8z"></path></symbol>
|
||||
</svg>
|
||||
|
||||
<main class="fleet-shell">
|
||||
<header class="fleet-header">
|
||||
<div class="orca-mark">O</div>
|
||||
<div class="fleet-title"><h1>Agents</h1><span>Fleet command</span></div>
|
||||
<div class="total-pill"><strong>168</strong><span>total</span></div>
|
||||
<nav class="concept-tabs" aria-label="Fleet concept">
|
||||
<button class="concept-tab is-active" data-view="constellation">Constellation</button>
|
||||
<button class="concept-tab" data-view="radar">Radar</button>
|
||||
<button class="concept-tab" data-view="matrix">Matrix</button>
|
||||
<button class="concept-tab" data-view="timeline">Timeline</button>
|
||||
<button class="concept-tab" data-view="orchestrator">Orchestrator</button>
|
||||
<button class="concept-tab" data-view="treemap">Treemap</button>
|
||||
</nav>
|
||||
<div class="header-tools">
|
||||
<label class="search-box"><svg class="icon"><use href="#i-search"></use></svg><input aria-label="Search fleet" placeholder="Find any agent…" /><kbd>⌘ K</kbd></label>
|
||||
<button class="icon-button"><svg class="icon"><use href="#i-filter"></use></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="concept-screen is-active" data-screen="constellation">
|
||||
<aside class="attention-dock">
|
||||
<div class="dock-heading"><span class="eyebrow">Needs you</span><strong>9</strong></div>
|
||||
<button class="attention-ticket is-hot"><span class="ticket-dot"></span><div><strong>review-11464</strong><small>Review loop needs decision</small></div><time>now</time></button>
|
||||
<button class="attention-ticket"><span class="ticket-dot"></span><div><strong>mobile-9717</strong><small>Choose duplicate policy</small></div><time>3m</time></button>
|
||||
<button class="attention-ticket"><span class="ticket-dot"></span><div><strong>trust-dialog</strong><small>Permission required</small></div><time>7m</time></button>
|
||||
<button class="attention-ticket"><span class="ticket-dot"></span><div><strong>+ 6 more</strong><small>Open intervention queue</small></div></button>
|
||||
<div class="dock-stats"><p><span>Working</span><strong>34</strong></p><p><span>Unread</span><strong>71</strong></p><p><span>Terminals</span><strong>332</strong></p></div>
|
||||
</aside>
|
||||
<div class="constellation-canvas">
|
||||
<div class="canvas-topline"><span><svg class="icon"><use href="#i-focus"></use></svg>All projects · Live topology</span><div><button>Hosts: 3</button><button>Auto layout</button></div></div>
|
||||
<svg class="fleet-edges" viewBox="0 0 1000 700" preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d="M500 350C390 260 330 210 220 180M500 350C600 250 690 220 790 170M500 350C630 370 750 400 840 460M500 350C400 440 340 520 230 555M500 350C540 480 570 540 620 610"></path>
|
||||
</svg>
|
||||
<button class="fleet-core"><span class="core-rings"></span><strong>ORCA FLEET</strong><small>168 agents · 104 live worktrees</small></button>
|
||||
<article class="constellation-cluster cluster-review selected">
|
||||
<header><div><strong>pr-8783-internal-review</strong><span>13 agents · 14 terminals</span></div><b>3!</b></header>
|
||||
<div class="node-cloud" data-dots="13" data-working="5"></div>
|
||||
<footer><span>Review loop</span><span>active now</span></footer>
|
||||
</article>
|
||||
<article class="constellation-cluster cluster-mobile">
|
||||
<header><div><strong>mobile-9717-dup-sessions</strong><span>12 agents · 13 terminals</span></div><b>2!</b></header>
|
||||
<div class="node-cloud" data-dots="12" data-working="3"></div>
|
||||
<footer><span>Mobile</span><span>3m</span></footer>
|
||||
</article>
|
||||
<article class="constellation-cluster cluster-pr">
|
||||
<header><div><strong>pr-9190-internal-review</strong><span>12 agents · 13 terminals</span></div></header>
|
||||
<div class="node-cloud" data-dots="12" data-working="4"></div>
|
||||
<footer><span>Review loop</span><span>6m</span></footer>
|
||||
</article>
|
||||
<article class="constellation-cluster cluster-trust">
|
||||
<header><div><strong>orca-trust-dialog</strong><span>5 agents · 7 terminals</span></div><b>1!</b></header>
|
||||
<div class="node-cloud" data-dots="5" data-working="2"></div>
|
||||
<footer><span>Desktop</span><span>7m</span></footer>
|
||||
</article>
|
||||
<article class="constellation-cluster cluster-resume">
|
||||
<header><div><strong>codex-resume-argv-drop</strong><span>5 agents · 6 terminals</span></div></header>
|
||||
<div class="node-cloud" data-dots="5" data-working="1"></div>
|
||||
<footer><span>Runtime</span><span>12m</span></footer>
|
||||
</article>
|
||||
<div class="canvas-controls"><button>⌖</button><button>−</button><span>42%</span><button>+</button><button><svg class="icon"><use href="#i-layout"></use></svg>Reflow</button></div>
|
||||
<div class="canvas-minimap"><i></i><span></span><b></b></div>
|
||||
</div>
|
||||
<aside class="context-panel">
|
||||
<div class="panel-kicker">Selected constellation</div>
|
||||
<h2>pr-8783-internal-review</h2>
|
||||
<div class="panel-summary"><span><strong>13</strong>agents</span><span><strong>5</strong>working</span><span class="attention-text"><strong>3</strong>need you</span></div>
|
||||
<section><span class="eyebrow">Agent lineage</span><div class="lineage-tree"><p class="root"><i class="working"></i>Review coordinator</p><p class="child"><i class="working"></i>Independent review</p><p class="grandchild"><i></i>Security audit</p><p class="grandchild"><i class="working"></i>Cross-platform pass</p><p class="child"><i></i>Test verification</p><p class="child attention-text"><i></i>Decision required</p></div></section>
|
||||
<section><span class="eyebrow">Latest signal</span><p class="signal-copy">Three review branches converged on the same runtime cleanup risk.</p><time>36 seconds ago</time></section>
|
||||
<div class="panel-actions"><button class="button primary">Open command center</button><button class="button">Open worktree</button></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="concept-screen" data-screen="radar">
|
||||
<aside class="radar-legend">
|
||||
<span class="eyebrow">Tactical radar</span>
|
||||
<h2>Fleet awareness</h2>
|
||||
<p>Distance from center represents urgency, not hierarchy.</p>
|
||||
<div class="legend-list"><p><i class="urgent"></i>Needs you <strong>9</strong></p><p><i class="active"></i>Working <strong>34</strong></p><p><i></i>Watching <strong>71</strong></p><p><i class="done"></i>Recently done <strong>54</strong></p></div>
|
||||
<div class="radar-filters"><button class="is-active">All hosts</button><button>Local</button><button>SSH</button><button>WSL</button></div>
|
||||
<div class="radar-note"><svg class="icon"><use href="#i-zap"></use></svg><span><strong>Signal detected</strong>3 agents crossed the 30-minute threshold.</span></div>
|
||||
</aside>
|
||||
<div class="radar-stage">
|
||||
<div class="radar-sweep"></div>
|
||||
<div class="radar-ring ring-one"><span>NEEDS YOU</span></div>
|
||||
<div class="radar-ring ring-two"><span>ACTIVE</span></div>
|
||||
<div class="radar-ring ring-three"><span>WATCH</span></div>
|
||||
<div class="radar-ring ring-four"><span>RECENT</span></div>
|
||||
<div class="radar-crosshair horizontal"></div><div class="radar-crosshair vertical"></div>
|
||||
<button class="radar-core"><strong>168</strong><span>agents online</span></button>
|
||||
<div id="radar-blips"></div>
|
||||
<button class="radar-label label-a"><i></i><strong>review-11464</strong><span>decision · now</span></button>
|
||||
<button class="radar-label label-b"><i></i><strong>mobile-9717</strong><span>2 alerts · 3m</span></button>
|
||||
<button class="radar-label label-c"><i></i><strong>pr-8783</strong><span>13 agents</span></button>
|
||||
</div>
|
||||
<aside class="signal-stream">
|
||||
<div class="stream-heading"><span class="eyebrow">Live signals</span><button>Pause</button></div>
|
||||
<article><i class="urgent"></i><div><strong>Question surfaced</strong><span>review-11464 · review coordinator</span></div><time>now</time></article>
|
||||
<article><i class="active"></i><div><strong>Subagent dispatched</strong><span>pr-8783 · security audit</span></div><time>22s</time></article>
|
||||
<article><i class="done"></i><div><strong>Tests completed</strong><span>mobile-6600 · 418 passed</span></div><time>1m</time></article>
|
||||
<article><i class="urgent"></i><div><strong>Long-running</strong><span>codex-resume · 47m active</span></div><time>2m</time></article>
|
||||
<article><i></i><div><strong>Review opened</strong><span>PR #11464 · runtime cleanup</span></div><time>4m</time></article>
|
||||
<article><i class="active"></i><div><strong>Agent resumed</strong><span>native-chat-multiline</span></div><time>6m</time></article>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="concept-screen" data-screen="matrix">
|
||||
<div class="matrix-shell">
|
||||
<div class="matrix-toolbar">
|
||||
<div><span class="eyebrow">Fleet matrix</span><h2>104 live worktrees</h2></div>
|
||||
<div class="matrix-metrics"><span><i class="urgent"></i><strong>9</strong>needs you</span><span><i class="active"></i><strong>34</strong>working</span><span><i></i><strong>71</strong>unread</span><span><svg class="icon"><use href="#i-terminal"></use></svg><strong>332</strong>terminals</span></div>
|
||||
<div class="matrix-actions"><button class="button">Group: project</button><button class="button">Density: compact</button></div>
|
||||
</div>
|
||||
<div class="matrix-grid">
|
||||
<div class="matrix-row matrix-head"><span>Worktree</span><span>Agent fabric</span><span>Needs you</span><span>Working</span><span>Done</span><span>Terminals</span><span>Last signal</span></div>
|
||||
<div id="matrix-rows"></div>
|
||||
</div>
|
||||
<div class="matrix-footer"><span>Showing 16 of 104 live worktrees</span><span>Scroll is virtualized · rows stay 38px</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="concept-screen" data-screen="timeline">
|
||||
<div class="timeline-shell">
|
||||
<div class="timeline-heading">
|
||||
<div><span class="eyebrow">Temporal command</span><h2>Where time is accumulating</h2></div>
|
||||
<div class="time-window"><button>1h</button><button class="is-active">4h</button><button>12h</button><button>24h</button></div>
|
||||
<div class="timeline-alert"><svg class="icon"><use href="#i-alert"></use></svg><strong>7 agents</strong><span>active longer than expected</span></div>
|
||||
</div>
|
||||
<div class="time-axis"><span>-4h</span><span>-3h</span><span>-2h</span><span>-1h</span><strong>NOW</strong></div>
|
||||
<div class="timeline-grid" id="timeline-grid"></div>
|
||||
</div>
|
||||
<aside class="outlier-panel">
|
||||
<span class="eyebrow">Outlier selected</span><h3>codex-resume-argv-drop</h3>
|
||||
<div class="outlier-agent"><span class="agent-avatar">C</span><div><strong>Resume ordering audit</strong><span>Working · 47m</span></div></div>
|
||||
<dl><div><dt>Expected</dt><dd>18m</dd></div><div><dt>Current</dt><dd class="attention-text">47m</dd></div><div><dt>Last output</dt><dd>2m ago</dd></div></dl>
|
||||
<p>No terminal output stall detected. Agent is running the full resume matrix on three host types.</p>
|
||||
<button class="button primary">Open live terminal</button>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="concept-screen" data-screen="orchestrator">
|
||||
<div class="workspace-backdrop">
|
||||
<aside><div class="fake-orca">O</div><span></span><span></span><span></span><span></span><span></span></aside>
|
||||
<div class="fake-editor"><div class="fake-tabs"><b></b><b></b><b></b></div><div class="code-lines"></div></div>
|
||||
</div>
|
||||
<section class="orchestrator-hud">
|
||||
<header><span class="drag-grip">⠿</span><div class="hud-identity"><span class="orch-badge">ORCH</span><strong>Orchestrator</strong><small>global · no worktree</small></div><div class="hud-health"><i></i>Fleet connected</div><button>—</button><button>□</button><button>×</button></header>
|
||||
<div class="hud-body">
|
||||
<div class="hud-command"><span>❯</span><strong>show me what needs intervention across the fleet</strong><kbd>↵</kbd></div>
|
||||
<div class="hud-answer">
|
||||
<div class="answer-heading"><svg class="icon"><use href="#i-zap"></use></svg><div><strong>9 agents need you</strong><span>Grouped into 4 decisions · 2 likely share the same cause</span></div><time>live</time></div>
|
||||
<article class="decision-card urgent"><div class="decision-index">01</div><div><strong>Runtime cleanup behavior</strong><span>review-11464 + pr-8783-internal-review</span><p>Three reviewers agree stale local rows should be dropped, but disagree on restart retention.</p></div><b>3 agents</b><button>Resolve</button></article>
|
||||
<article class="decision-card"><div class="decision-index">02</div><div><strong>Duplicate mobile sessions</strong><span>mobile-9717-dup-sessions</span><p>Choose whether restored sessions replace or merge with live host state.</p></div><b>2 agents</b><button>Open</button></article>
|
||||
<article class="decision-card"><div class="decision-index">03</div><div><strong>Trust dialog permission</strong><span>orca-trust-dialog</span><p>One test agent needs approval to continue accessibility validation.</p></div><b>1 agent</b><button>Approve</button></article>
|
||||
</div>
|
||||
<div class="suggested-commands"><span>Try</span><button>agents older than 30m</button><button>summarize PR reviews</button><button>focus mobile fleet</button><button>show failures</button></div>
|
||||
</div>
|
||||
<footer><span><kbd>⌘ ⇧ O</kbd> summon</span><span><kbd>⌘ J</kbd> next alert</span><span><kbd>Esc</kbd> dismiss</span><i></i><strong>168 agents · 332 terminals · 3 hosts</strong></footer>
|
||||
</section>
|
||||
<div class="back-trail"><button>← Orchestrator</button><span>All agents</span><b>›</b><span>review-11464</span></div>
|
||||
</section>
|
||||
|
||||
<section class="concept-screen" data-screen="treemap">
|
||||
<div class="treemap-shell">
|
||||
<div class="treemap-heading"><div><span class="eyebrow">Mission treemap</span><h2>Space equals agent count · edge glow equals urgency</h2></div><div class="treemap-legend"><span><i class="urgent"></i>Needs you</span><span><i class="active"></i>Working</span><span><i></i>Quiet</span></div><button class="button">Size by: agents</button></div>
|
||||
<div class="treemap">
|
||||
<section class="project-zone zone-orca"><header><div><svg class="icon"><use href="#i-folder"></use></svg><strong>ORCA</strong><span>112 agents</span></div><b>6 need you</b></header>
|
||||
<button class="tree-tile tile-8783 urgent"><strong>pr-8783-internal-review</strong><span>13 agents · 5 working</span><div class="tiny-agents" data-dots="13" data-working="5"></div></button>
|
||||
<button class="tree-tile tile-9190"><strong>pr-9190-internal-review</strong><span>12 agents · 4 working</span><div class="tiny-agents" data-dots="12" data-working="4"></div></button>
|
||||
<button class="tree-tile tile-11464 urgent"><strong>review-11464</strong><span>3 agents · decision</span><div class="tiny-agents" data-dots="3" data-working="3"></div></button>
|
||||
<button class="tree-tile tile-trust"><strong>orca-trust-dialog</strong><span>5 agents</span><div class="tiny-agents" data-dots="5" data-working="2"></div></button>
|
||||
<button class="tree-tile tile-resume"><strong>codex-resume-argv-drop</strong><span>5 agents · 47m</span><div class="tiny-agents" data-dots="5" data-working="1"></div></button>
|
||||
<button class="tree-tile tile-other"><strong>+ 47 worktrees</strong><span>74 agents</span><div class="aggregate-bars"><i></i><i></i><i></i><i></i></div></button>
|
||||
</section>
|
||||
<section class="project-zone zone-mobile"><header><div><svg class="icon"><use href="#i-folder"></use></svg><strong>MOBILE</strong><span>39 agents</span></div><b>2 need you</b></header>
|
||||
<button class="tree-tile tile-mobile-main urgent"><strong>mobile-9717-dup-sessions</strong><span>12 agents · 3 working</span><div class="tiny-agents" data-dots="12" data-working="3"></div></button>
|
||||
<button class="tree-tile tile-mobile-save"><strong>mobile-6600-save-host</strong><span>6 agents · 9 terminals</span><div class="tiny-agents" data-dots="6" data-working="2"></div></button>
|
||||
<button class="tree-tile tile-mobile-ime"><strong>mobile-ime-6995</strong><span>5 agents</span><div class="tiny-agents" data-dots="5" data-working="1"></div></button>
|
||||
<button class="tree-tile tile-mobile-more"><strong>+ 9 worktrees</strong><span>16 agents</span></button>
|
||||
</section>
|
||||
<section class="project-zone zone-relay"><header><div><svg class="icon"><use href="#i-folder"></use></svg><strong>RELAY + OTHER</strong><span>17 agents</span></div><b>1 need you</b></header>
|
||||
<button class="tree-tile tile-relay"><strong>connection recovery</strong><span>8 agents</span><div class="tiny-agents" data-dots="8" data-working="2"></div></button>
|
||||
<button class="tree-tile tile-docs"><strong>docs + tooling</strong><span>9 agents</span><div class="tiny-agents" data-dots="9" data-working="1"></div></button>
|
||||
</section>
|
||||
</div>
|
||||
<div class="treemap-inspector"><span>Hover or click a region to zoom</span><strong>Fleet coverage 100%</strong><button><svg class="icon"><use href="#i-focus"></use></svg>Fit all</button></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="./agent-fleet-concepts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
const tabs = [...document.querySelectorAll('[data-view]')]
|
||||
const screens = [...document.querySelectorAll('[data-screen]')]
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
if (!isMac) {
|
||||
const shortcutLabels = new Map([
|
||||
['⌘ K', 'Ctrl+K'],
|
||||
['⌘ ⇧ O', 'Ctrl+Shift+O'],
|
||||
['⌘ J', 'Ctrl+J']
|
||||
])
|
||||
document.querySelectorAll('kbd').forEach((key) => {
|
||||
key.textContent = shortcutLabels.get(key.textContent.trim()) ?? key.textContent
|
||||
})
|
||||
}
|
||||
|
||||
function showView(view) {
|
||||
tabs.forEach((tab) => tab.classList.toggle('is-active', tab.dataset.view === view))
|
||||
screens.forEach((screen) => screen.classList.toggle('is-active', screen.dataset.screen === view))
|
||||
if (window.location.hash.slice(1) !== view) window.location.hash = view
|
||||
}
|
||||
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => showView(tab.dataset.view)))
|
||||
window.addEventListener('hashchange', () => {
|
||||
const view = window.location.hash.slice(1)
|
||||
if (screens.some((screen) => screen.dataset.screen === view)) showView(view)
|
||||
})
|
||||
|
||||
function populateDots(selector) {
|
||||
document.querySelectorAll(selector).forEach((cloud) => {
|
||||
const count = Number(cloud.dataset.dots ?? 0)
|
||||
const working = Number(cloud.dataset.working ?? 0)
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const dot = document.createElement('i')
|
||||
if (index < working) dot.className = 'is-working'
|
||||
if (index === working && cloud.closest('.urgent')) dot.className = 'is-urgent'
|
||||
cloud.append(dot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
populateDots('.node-cloud, .tiny-agents')
|
||||
|
||||
const radar = document.querySelector('#radar-blips')
|
||||
if (radar) {
|
||||
const counts = [
|
||||
{ total: 9, min: 16, max: 27, type: 'urgent' },
|
||||
{ total: 34, min: 29, max: 47, type: 'active' },
|
||||
{ total: 71, min: 49, max: 68, type: '' },
|
||||
{ total: 54, min: 70, max: 89, type: 'done' }
|
||||
]
|
||||
let seed = 17
|
||||
const random = () => {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
counts.forEach((group) => {
|
||||
for (let index = 0; index < group.total; index += 1) {
|
||||
const angle = random() * Math.PI * 2
|
||||
const radius = group.min + random() * (group.max - group.min)
|
||||
const blip = document.createElement('i')
|
||||
blip.className = group.type
|
||||
blip.style.left = `${50 + Math.cos(angle) * radius * 0.5}%`
|
||||
blip.style.top = `${50 + Math.sin(angle) * radius * 0.5}%`
|
||||
radar.append(blip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const worktrees = [
|
||||
['pr-8783-internal-review', 13, 3, 5, 5, 14, 'now'],
|
||||
['mobile-9717-dup-sessions', 12, 2, 3, 7, 13, '22s'],
|
||||
['pr-9190-internal-review', 12, 0, 4, 8, 13, '41s'],
|
||||
['mobile-6600-save-host', 6, 1, 2, 3, 9, '1m'],
|
||||
['mobile-pr-10148', 6, 0, 2, 4, 2, '2m'],
|
||||
['orca-trust-dialog', 5, 1, 2, 2, 7, '3m'],
|
||||
['codex-resume-argv-drop', 5, 0, 1, 4, 6, '3m'],
|
||||
['mobile-ime-6995', 5, 0, 1, 4, 5, '5m'],
|
||||
['mobile-6927-close-tabs', 5, 0, 2, 3, 4, '7m'],
|
||||
['native-chat-draft-reachable', 4, 0, 2, 2, 6, '8m'],
|
||||
['review-11464', 3, 1, 3, 0, 5, 'now'],
|
||||
['i18n-architecture', 3, 0, 1, 2, 5, '11m'],
|
||||
['review-pr-9177-2', 3, 0, 2, 1, 3, '12m'],
|
||||
['repro-6713-input-dead', 2, 1, 1, 1, 5, '14m'],
|
||||
['sta2373-respawn-deadsock', 2, 0, 1, 1, 6, '17m'],
|
||||
['pty-provider-types-split', 4, 0, 1, 3, 4, '19m']
|
||||
]
|
||||
|
||||
const matrixRows = document.querySelector('#matrix-rows')
|
||||
worktrees.forEach(([name, total, needs, working, done, terminals, signal]) => {
|
||||
if (!matrixRows) return
|
||||
const row = document.createElement('button')
|
||||
row.className = `matrix-row${needs ? ' has-attention' : ''}`
|
||||
const dots = Array.from({ length: total }, (_, index) => `<i class="${index < needs ? 'urgent' : index < needs + working ? 'active' : 'done'}"></i>`).join('')
|
||||
row.innerHTML = `<span><svg class="icon"><use href="#i-folder"></use></svg><strong>${name}</strong><small>${total} agents</small></span><span class="fabric">${dots}</span><span class="${needs ? 'attention-text' : ''}">${needs || '—'}</span><span>${working}</span><span>${done}</span><span>${terminals}</span><span>${signal}</span>`
|
||||
matrixRows.append(row)
|
||||
})
|
||||
|
||||
const timeline = document.querySelector('#timeline-grid')
|
||||
worktrees.slice(0, 12).forEach(([name, total, needs, working], rowIndex) => {
|
||||
if (!timeline) return
|
||||
const row = document.createElement('div')
|
||||
row.className = `timeline-row${name === 'codex-resume-argv-drop' ? ' is-selected' : ''}`
|
||||
const segments = Array.from({ length: Math.min(total, 7) }, (_, index) => {
|
||||
const left = 6 + ((rowIndex * 17 + index * 13) % 60)
|
||||
const width = 8 + ((rowIndex * 7 + index * 9) % 24)
|
||||
const type = index < needs ? 'urgent' : index < working ? 'active' : 'done'
|
||||
return `<i class="time-segment ${type}" style="left:${left}%;width:${Math.min(width, 94 - left)}%"></i>`
|
||||
}).join('')
|
||||
row.innerHTML = `<span class="lane-name"><strong>${name}</strong><small>${total} agents</small></span><span class="lane-track">${segments}</span><span class="lane-count">${working} active</span>`
|
||||
timeline.append(row)
|
||||
})
|
||||
|
||||
const initialView = window.location.hash.slice(1)
|
||||
if (screens.some((screen) => screen.dataset.screen === initialView)) showView(initialView)
|
||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,144 @@
|
||||
# Agent Fleet Control Room
|
||||
|
||||
Third-generation mocks (after `agent-fleet-concepts/`, `agent-fleet-refined/`,
|
||||
`agent-viewer-alternatives/`), built from a research pass over infrastructure fleet
|
||||
dashboards (Nomad topology, kube-ops-view, Kiali, Datadog host maps, Vizceral, Weave
|
||||
Scope, Grafana, Honeycomb), AI-agent products (Claude Code Agent View, Devin Command
|
||||
Center, LangChain Agent Inbox, Cursor/Copilot/Codex session lists), and control-room
|
||||
prior art (ATC flight strips, NASA-STD-3001 / Apollo caution-and-warning, ISA-18.2
|
||||
alarm management, RTS conventions).
|
||||
|
||||
## Tenets
|
||||
|
||||
1. **Whole fleet always accounted for** — every agent has a representation from
|
||||
dispatch to explicit dismissal; an accounting line proves it ("104 of 246 agents").
|
||||
2. **State is preattentive** — color, glyph shape, and motion rate communicate before
|
||||
any text is read. Reading is progressive: hover, then click.
|
||||
3. **One step from seeing to responding, in place** — answer a question, grant a
|
||||
permission, or review a diff without leaving the board; attaching a terminal is the
|
||||
escalation, never the prerequisite.
|
||||
4. **Spatial stability** — position is a deterministic function of (project, worktree,
|
||||
start time). Nothing re-sorts by a live metric; users build spatial memory.
|
||||
5. **Nothing falls through the cracks** — finished work latches into TO REVIEW until
|
||||
explicitly reviewed (ISA-18.2 `RTNUN`; Apollo: acknowledging silences, never clears).
|
||||
6. **The actionable unit is a decision** — six agents blocked on the same permission
|
||||
render as one card; one response unblocks all of them.
|
||||
7. **Degrades gracefully 5 → 150+** — labels hide before agents do; folds always say
|
||||
what they hid; failures and open PRs never fold.
|
||||
8. **Liveness is the aesthetic** — motion means "not settled": fast blink = needs you,
|
||||
slow pulse = finished-unreviewed, still = quiet fleet. Working agents get at most a
|
||||
dim slow spinner. A motionless board is the good outcome, not a dead one.
|
||||
|
||||
## Key research findings baked in
|
||||
|
||||
- Every serious topology tool abandoned force-directed layout (Kiali deleted its
|
||||
physics layouts; Nomad never had one). Position here is **pure containment** —
|
||||
project → worktree → agent — with **on-demand lineage edges** drawn only for the
|
||||
selected agent (Nomad's sibling-highlight mechanic).
|
||||
- **Grayscale-normal chromatic budget** (ISA-101 / High-Performance HMI, and Orca's own
|
||||
styleguide): chrome is monochrome, saturated hue appears only on states that need a
|
||||
human. Working is deliberately dim.
|
||||
- **Color never rides alone**: yellow-500 (working) vs amber-500 (needs input) measures
|
||||
ΔE 5.3 for normal vision and 2.5 for deuteranopia (validated) — every state pairs its
|
||||
hue with a distinct glyph shape, and liveness (process alive / exited / loop-sleeping)
|
||||
is a separate channel, per Claude Code Agent View.
|
||||
- **Three-rate annunciation** (ISA-18.1 Sequence R): fast border-blink = unacknowledged
|
||||
needs-you; slow pulse = returned-to-normal-but-unreviewed; steady = acknowledged.
|
||||
Blink the border between neutral and priority color — never fill or text — and stop
|
||||
the moment a human acknowledges.
|
||||
- **Self-clearing count-and-cycle badges** (StarCraft's idle-worker button): the badge
|
||||
counts neglected items, pressing it cycles through them, and it vanishes when the
|
||||
category empties.
|
||||
- **Coalesced decisions** (LangChain Agent Inbox affordances + ISA-18.2 alarm
|
||||
rationalization + a Honeycomb-BubbleUp commonality line): no shipping product does
|
||||
this; it is the feature that makes 150 agents supervisable.
|
||||
- Datadog's refresh rule: **auto-refresh is suppressed while the user interacts.**
|
||||
Kiali's loudest complaint was refresh resetting zoom — never reset the viewport.
|
||||
|
||||
## The mocks
|
||||
|
||||
- **`fleet-map.html`** — the observability flagship. Nomad-style containment map with
|
||||
k9s-Pulse dials, hover-only numbers, split-right respond-in-place panel, on-demand
|
||||
lineage edges, filter-by-desaturation (matches keep their position; nothing reflows).
|
||||
- **`fleet-strips.html`** — the intervention flagship. ATC strip bays as states
|
||||
(NEEDS YOU / WORKING / TO REVIEW / DONE), coalesced decision cards first, questions
|
||||
verbatim on blocked strips, peek-with-numbered-replies, cocked-strip pinning, latched
|
||||
review bay, DONE folded with "PRs and failures never fold".
|
||||
- **`mission-control.html`** — the hybrid: containment map (left) + intervention rail
|
||||
(right) + dials, with map↔rail selection linkage showing they are one surface.
|
||||
- **`fleet-hexmap.html`** — the minimal/modern bar (Datadog host-map lineage): a packed
|
||||
hexagon field with zero text — quiet dark glass, 11 glowing cells, words only in the
|
||||
hover tooltip and a slide-in respond panel.
|
||||
- **`fleet-terminal-wall.html`** — "lots of little agents doing work": a video wall of
|
||||
104 miniature terminals. Working tiles genuinely type and scroll (staggered,
|
||||
deterministic content, paced by timers, reduced-motion gated); needs-you tiles end on
|
||||
the question or permission line; the settled fleet is a silent micro-tile strip.
|
||||
- **`fleet-inbox.html`** — the calm single-column take: approval cards, top question
|
||||
expanded with choice pills, the rest one line each, remaining fleet as three grouped
|
||||
rows. (Feedback: right idea, execution too literally iOS.)
|
||||
- **`fleet-triage.html`** — the daily driver: the same "what needs me?" spine in a
|
||||
desktop-native split view (Linear-inbox shape). 420px list — Needs you / Working /
|
||||
Ready to review / Done with counts — and a detail pane where the selected item carries
|
||||
its full context and actions: Allow / Allow-for-project / Deny on a coalesced
|
||||
permission, numbered answers on a question, live tail, arrow-key navigation.
|
||||
Hierarchy by size and spacing; the only color is a 6px state dot per row.
|
||||
- **`fleet-glass.html`** — the answer to "the board is boring / late-90s task list":
|
||||
board's structure rendered as living material. Same worktree-card containment and
|
||||
reading order, but: neutral-gradient glass cards with inner highlights and soft
|
||||
depth, agents as glowing ORBS (words exist only on needs-you ask-lines), a
|
||||
typographic stat band (34px numerals, amber glow on the needs-you count),
|
||||
needs-you/failed cards bloom with edge light, working orbs breathe, hover-lift,
|
||||
floating glass respond panel. Depth comes from neutral gradients and glow only —
|
||||
no new hues; the elevated-surface treatment is the one deliberate extension
|
||||
beyond the app's flat tokens.
|
||||
- **`fleet-space.html`** — glass set free: the same glass worktree cards on an OPEN
|
||||
pan/zoom field (no rings, no containers). Deterministic masonry clusters per project
|
||||
with hash jitter for an organic first layout; every card individually draggable with
|
||||
positions persisted to localStorage (drag the furniture — agents stay orbs inside);
|
||||
floating project labels; scroll-zoom toward cursor; Fit / Reset layout; the glass
|
||||
respond panel floats over the field.
|
||||
- **`fleet-board.html`** — the correction after fleet-tiles ("impossible to tell where
|
||||
one worktree ends"): the WORKTREE CARD is the unit of the design. Projects as
|
||||
eyebrow sections, worktree cards in a strict reading-order grid (needs-you first,
|
||||
then alphabetical), agents as quiet rows inside, ask-lines on a sub-rail, styled to
|
||||
Orca's real kanban-card idiom (12px/11px, tabular-nums, hairlines). Zoom is DENSITY,
|
||||
not 2D scatter: Compact (whole fleet on one screen — header slabs + dot clusters) /
|
||||
Normal / Detail (inline terminal tails), via segmented control, ⌃scroll, or `?d=`.
|
||||
Respond panel insets the board instead of covering it. Captures:
|
||||
`fleet-board-compact.png` / `fleet-board.png` / `fleet-board-detail.png`.
|
||||
- **`fleet-tiles.html`** — the semantic-zoom tile canvas (rounded tiles, one primitive,
|
||||
three altitudes): squircle chip at far zoom → name/state/PR card at mid → live typing
|
||||
terminal up close. Repo islands and worktree groups are draggable (positions persist
|
||||
to localStorage; agents auto-place inside — drag the furniture, not the people), with
|
||||
a minimap (click/drag to move the viewport), F cycling needs-you, and the shared
|
||||
respond-in-place panel. Band captures: `fleet-tiles-far.png` / `fleet-tiles.png` (mid)
|
||||
/ `fleet-tiles-near.png`, via `capture-tiles-bands.mjs`.
|
||||
- **`fleet-canvas.html`** — the revival of Brennan's April-2026 movable concentric
|
||||
dashboard (branch `brennanb2025/orca-agent-status-dashboard-movable-ui`, tip
|
||||
`6416b2c3e3`): an infinite pan/zoom SVG canvas of repo systems — core, orbit rings,
|
||||
worktree clusters, log-duration agent nodes — with deterministic hash-derived
|
||||
placement, zoom-as-altitude labels (repo names always; worktree labels at mid-zoom
|
||||
with overlap thinning; questions beside amber nodes up close), on-demand cross-system
|
||||
lineage arcs, and the hexmap's slide-in respond panel. Prior art studied: OpenCove
|
||||
(DeadWaveWave) for the infinite-canvas + minimap pattern.
|
||||
|
||||
## Data
|
||||
|
||||
All three render the **real captured fleet** from
|
||||
`../agent-viewer-alternatives/actual-agent-activity.js` (104 agents, 59 worktrees,
|
||||
snapshot of 246-agent / 106-worktree fleet). The capture only distinguishes
|
||||
working/done, so `fleet-state-synthesis.js` deterministically synthesizes (FNV-1a over
|
||||
agent id — identical every reload) the states the CLI doesn't report yet: 3 questions,
|
||||
6 permission requests coalesced into 2 decisions, 2 failures, a 14-item review latch,
|
||||
liveness, PRs, pins, and display-project grouping (`orca` / `orca-mobile` /
|
||||
`automation`).
|
||||
|
||||
`control-room-tokens.css` copies the dark-theme values from
|
||||
`src/renderer/src/assets/main.css` and the agent-state colors from `AgentStateDot.tsx`;
|
||||
no new color values were invented.
|
||||
|
||||
## Viewing
|
||||
|
||||
Open any of the three HTML files directly (`file://` works), or serve the repo root:
|
||||
`python3 -m http.server 4173`. Screenshots were captured at 1600×1000 via
|
||||
`npx playwright screenshot`.
|
||||
@@ -0,0 +1,41 @@
|
||||
/* Screenshots the control-room mocks with the repo's playwright-core.
|
||||
Uses the full cached chromium (not the headless shell, which may be absent).
|
||||
Usage: node capture-control-room.mjs [name ...] (default: all three) */
|
||||
import { chromium } from 'playwright-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const names = process.argv.slice(2).length
|
||||
? process.argv.slice(2)
|
||||
: ['fleet-map', 'fleet-strips', 'mission-control', 'fleet-hexmap'];
|
||||
|
||||
const fullChromium = path.join(
|
||||
homedir(),
|
||||
'Library/Caches/ms-playwright/chromium-1217/chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium',
|
||||
);
|
||||
|
||||
const launchOpts = existsSync(fullChromium)
|
||||
? { executablePath: fullChromium, headless: true }
|
||||
: existsSync('/Applications/Google Chrome.app')
|
||||
? { channel: 'chrome', headless: true }
|
||||
: { headless: true };
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
for (const name of names) {
|
||||
const file = path.join(here, `${name}.html`);
|
||||
if (!existsSync(file)) {
|
||||
console.error(`skip ${name}: no ${file}`);
|
||||
continue;
|
||||
}
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(String(e)));
|
||||
await page.goto(`file://${file}`);
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: path.join(here, `${name}.png`) });
|
||||
console.log(`${name}.png captured${errors.length ? ` — PAGE ERRORS: ${errors.join(' | ')}` : ''}`);
|
||||
errors.length = 0;
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,44 @@
|
||||
/* Captures fleet-tiles at its three zoom bands (far/mid/near) by dispatching
|
||||
pinch-wheel events. Usage: node capture-tiles-bands.mjs */
|
||||
import { chromium } from 'playwright-core';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const launchOpts = existsSync('/Applications/Google Chrome.app')
|
||||
? { channel: 'chrome', headless: true }
|
||||
: { headless: true };
|
||||
|
||||
const browser = await chromium.launch(launchOpts);
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
||||
|
||||
async function zoomUntil(band, deltaY, maxTicks = 80) {
|
||||
for (let i = 0; i < maxTicks; i++) {
|
||||
const current = await page.evaluate((dy) => {
|
||||
const stage = document.querySelector('.stage');
|
||||
stage.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: dy, ctrlKey: true, clientX: 620, clientY: 500,
|
||||
bubbles: true, cancelable: true,
|
||||
}),
|
||||
);
|
||||
return stage.dataset.zoom;
|
||||
}, deltaY);
|
||||
if (current === band) return current;
|
||||
}
|
||||
return page.evaluate(() => document.querySelector('.stage').dataset.zoom);
|
||||
}
|
||||
|
||||
for (const [name, band, deltaY] of [
|
||||
['fleet-tiles-far', 'far', 60],
|
||||
['fleet-tiles-near', 'near', -60],
|
||||
]) {
|
||||
await page.goto(`file://${path.join(here, 'fleet-tiles.html')}`);
|
||||
await page.waitForTimeout(900);
|
||||
const reached = await zoomUntil(band, deltaY);
|
||||
await page.waitForTimeout(band === 'near' ? 2200 : 400);
|
||||
await page.screenshot({ path: path.join(here, `${name}.png`) });
|
||||
console.log(`${name}.png captured (band: ${reached})`);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Shared tokens for the control-room mocks.
|
||||
Values copied from src/renderer/src/assets/main.css (.dark) and AgentStateDot.tsx —
|
||||
do not invent new colors; chrome stays monochrome, hue is reserved for agent state. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('../src/renderer/src/assets/fonts/Geist-Variable.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--background: #0a0a0a;
|
||||
--card: #171717;
|
||||
--popover: #171717;
|
||||
--secondary: #262626;
|
||||
--muted: #262626;
|
||||
--accent: #404040;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--input: rgb(255 255 255 / 0.15);
|
||||
--ring: #737373;
|
||||
|
||||
/* Text */
|
||||
--foreground: #fafafa;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--primary: #e5e5e5;
|
||||
--primary-foreground: #171717;
|
||||
|
||||
/* Agent state — the ONLY saturated hues on screen. Never used without a glyph
|
||||
shape: yellow≈amber fails CVD separation (ΔE 2.5 deutan), shape must carry it. */
|
||||
--state-working: #eab308; /* yellow-500 — rendered dim/desaturated at rest */
|
||||
--state-attention: #f59e0b; /* amber-500 — waiting on the user */
|
||||
--state-blocked: #ef4444; /* red-500 — failed / blocked */
|
||||
--state-done: #10b981; /* emerald-500 — finished */
|
||||
--state-idle: rgb(115 115 115 / 0.4);
|
||||
|
||||
--glow-attention: 0 0 7px color-mix(in srgb, var(--state-attention) 70%, transparent);
|
||||
--glow-working: 0 0 6px color-mix(in srgb, var(--state-working) 45%, transparent);
|
||||
--glow-done: 0 0 6px color-mix(in srgb, var(--state-done) 48%, transparent);
|
||||
|
||||
/* Radius / shadow — three shadow tiers only: hairline border, subtle lift, floating */
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--radius-lg: 14px;
|
||||
--shadow-lift: 0 1px 2px rgb(0 0 0 / 0.3);
|
||||
--shadow-floating: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
|
||||
/* Type */
|
||||
--font-ui: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace;
|
||||
|
||||
/* Motion grammar (ISA-18.1 sequence R, web-tempered):
|
||||
fast = unacknowledged needs-you · slow = finished-unreviewed · steady = everything else */
|
||||
--blink-fast: 0.9s;
|
||||
--pulse-slow: 2.8s;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.01em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
/* Border blinks between neutral and priority color — never fill, never text,
|
||||
so the card is legible in both phases and never vanishes from its slot. */
|
||||
@keyframes blink-border-attention {
|
||||
0%, 44% { border-color: var(--state-attention); box-shadow: var(--glow-attention); }
|
||||
50%, 94% { border-color: var(--border); box-shadow: none; }
|
||||
}
|
||||
@keyframes blink-border-blocked {
|
||||
0%, 44% { border-color: var(--state-blocked); box-shadow: 0 0 7px color-mix(in srgb, var(--state-blocked) 60%, transparent); }
|
||||
50%, 94% { border-color: var(--border); box-shadow: none; }
|
||||
}
|
||||
@keyframes pulse-review {
|
||||
0%, 100% { border-color: color-mix(in srgb, var(--state-done) 55%, var(--border)); }
|
||||
50% { border-color: var(--border); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,490 @@
|
||||
/* Fleet · Board — the worktree card is the unit of the design.
|
||||
Projects are sections, worktrees are surfaces, agents are quiet rows inside.
|
||||
Zoom is density (compact / normal / detail), navigation is scroll. */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- chrome ---------------------------------------------------------------- */
|
||||
|
||||
.bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.tabs a {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tabs a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.tabs .is-active {
|
||||
color: var(--foreground);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.chips span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.chips b {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.density {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.density button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: 4px 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.density button + button {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.density button.is-active {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 190px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.search::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.search:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
}
|
||||
.accounting {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- board ----------------------------------------------------------------- */
|
||||
|
||||
.board {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 24px 40px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--accent) transparent;
|
||||
transition: padding-right 190ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
body.panel-open .board {
|
||||
padding-right: 396px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin: 26px 2px 12px;
|
||||
}
|
||||
.section-head:first-child {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.section-head .meta {
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
|
||||
}
|
||||
.section-head .rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(258px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ---- worktree card: the unit --------------------------------------------- */
|
||||
|
||||
.wt {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px 9px;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
.wt.is-dim {
|
||||
opacity: 0.22;
|
||||
}
|
||||
/* The card carries its worst state on a hairline, quietly. */
|
||||
.wt[data-worst='attention'],
|
||||
.wt[data-worst='permission'] {
|
||||
border-color: color-mix(in srgb, var(--state-attention) 45%, var(--border));
|
||||
}
|
||||
.wt[data-worst='failed'] {
|
||||
border-color: color-mix(in srgb, var(--state-blocked) 55%, var(--border));
|
||||
}
|
||||
.wt[data-worst='review'] {
|
||||
border-color: color-mix(in srgb, var(--state-done) 32%, var(--border));
|
||||
}
|
||||
|
||||
.wt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wt-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wt-count {
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.unread-dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- agent rows ------------------------------------------------------------ */
|
||||
|
||||
.agent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
border-radius: 6px;
|
||||
padding: 0 4px;
|
||||
margin: 0 -4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.agent:hover {
|
||||
background: color-mix(in srgb, var(--secondary) 55%, transparent);
|
||||
}
|
||||
.agent.is-selected {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot[data-state='working'] {
|
||||
background: transparent;
|
||||
border: 1.5px solid color-mix(in srgb, var(--state-working) 75%, transparent);
|
||||
border-top-color: transparent;
|
||||
animation: spin 2.4s linear infinite;
|
||||
}
|
||||
.dot[data-state='attention'],
|
||||
.dot[data-state='permission'] {
|
||||
background: var(--state-attention);
|
||||
box-shadow: var(--glow-attention);
|
||||
animation: dot-blink var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
.dot[data-state='failed'] {
|
||||
background: var(--state-blocked);
|
||||
animation: dot-blink var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
.dot[data-state='review'] {
|
||||
background: color-mix(in srgb, var(--state-done) 85%, transparent);
|
||||
}
|
||||
.dot[data-state='done'] {
|
||||
background: var(--state-idle);
|
||||
}
|
||||
@keyframes dot-blink {
|
||||
0%, 44% { opacity: 1; }
|
||||
50%, 94% { opacity: 0.25; }
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.agent.is-loud .agent-name {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.agent-age {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.agent-pr {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The line that needs you: the question/command/failure, shown under the row. */
|
||||
.agent-ask {
|
||||
margin: 0 0 6px 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: color-mix(in srgb, var(--foreground) 90%, transparent);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* ---- density: compact ------------------------------------------------------ */
|
||||
|
||||
.board[data-density='compact'] .grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(196px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.board[data-density='compact'] .wt {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.board[data-density='compact'] .agent {
|
||||
display: none;
|
||||
}
|
||||
.board[data-density='compact'] .agent-ask {
|
||||
display: none;
|
||||
}
|
||||
.board[data-density='compact'] .wt-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.board[data-density='compact'] .dots-row {
|
||||
display: flex;
|
||||
}
|
||||
.dots-row {
|
||||
display: none;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
.board[data-density='compact'] .wt-name {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* ---- density: detail ------------------------------------------------------- */
|
||||
|
||||
.tail {
|
||||
display: none;
|
||||
}
|
||||
.board[data-density='detail'] .tail {
|
||||
display: block;
|
||||
margin: 0 0 8px 19px;
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--background) 72%, var(--card));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tail .now {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.board[data-density='detail'] .grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
}
|
||||
|
||||
/* ---- respond panel --------------------------------------------------------- */
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
width: 372px;
|
||||
background: var(--card);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-floating);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
z-index: 30;
|
||||
}
|
||||
.panel h1 {
|
||||
margin: 0 34px 8px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.panel .close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel .state-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.panel .path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.panel .tail {
|
||||
display: block;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.panel .question {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.panel .opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.panel .opts button,
|
||||
.panel .actions button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel .opts button:hover,
|
||||
.panel .actions button:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.panel .opts kbd {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.panel .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.panel .actions .primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
.panel .reply {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.panel .reply input {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.panel .reply input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
/* ---- footer whisper -------------------------------------------------------- */
|
||||
|
||||
.whisper {
|
||||
flex: 0 0 auto;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
padding: 0 16px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.whisper span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.whisper i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fleet — Board</title>
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-board.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<span class="wordmark">Fleet</span>
|
||||
<nav class="tabs">
|
||||
<a href="./fleet-tiles.html">Tiles</a>
|
||||
<a href="./fleet-canvas.html">Canvas</a>
|
||||
<a href="./fleet-triage.html">Triage</a>
|
||||
<span class="is-active">Board</span>
|
||||
</nav>
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="density" id="density" role="tablist" aria-label="Density">
|
||||
<button data-d="compact">Compact</button>
|
||||
<button data-d="normal" class="is-active">Normal</button>
|
||||
<button data-d="detail">Detail</button>
|
||||
</div>
|
||||
<input class="search" id="search" type="text" />
|
||||
<span class="accounting" id="accounting"></span>
|
||||
</header>
|
||||
|
||||
<main class="board" id="board" data-density="normal"></main>
|
||||
|
||||
<aside class="panel" id="panel" hidden></aside>
|
||||
|
||||
<footer class="whisper" id="whisper"></footer>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-board.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,234 @@
|
||||
/* Fleet · Board — worktree cards in reading order, from window.FLEET. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const F = window.FLEET;
|
||||
const MAC = navigator.userAgent.includes('Mac');
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
const STATE_LABEL = {
|
||||
attention: 'Needs an answer', permission: 'Awaiting permission', failed: 'Failed',
|
||||
working: 'Working', review: 'Ready to review', done: 'Done',
|
||||
};
|
||||
|
||||
// ---- chrome ---------------------------------------------------------------
|
||||
const chips = document.getElementById('chips');
|
||||
[
|
||||
['attention', F.counts.needsYou, 'need you'],
|
||||
['working', F.counts.working, 'working'],
|
||||
['review', F.counts.review, 'to review'],
|
||||
['done', F.counts.done, 'done'],
|
||||
].forEach(([state, n, label]) => {
|
||||
const c = el('span');
|
||||
const dot = el('i', 'dot');
|
||||
dot.dataset.state = state === 'attention' ? 'attention' : state;
|
||||
if (state !== 'attention') dot.style.animation = 'none';
|
||||
dot.style.width = '6px';
|
||||
dot.style.height = '6px';
|
||||
c.append(dot, el('b', null, String(n)), el('span', null, label));
|
||||
chips.append(c);
|
||||
});
|
||||
document.getElementById('accounting').textContent =
|
||||
F.counts.total + ' / ' + F.counts.fleetTotal + ' agents · ' +
|
||||
F.byWorktree.length + ' / ' + F.counts.fleetWorktrees + ' worktrees';
|
||||
document.getElementById('search').placeholder = 'Filter… ' + (MAC ? '⌘' : 'Ctrl+') + 'K';
|
||||
|
||||
// ---- board ----------------------------------------------------------------
|
||||
const board = document.getElementById('board');
|
||||
const agentRows = new Map();
|
||||
let selected = null;
|
||||
|
||||
function tailBlock(a, lines) {
|
||||
const pre = el('pre', 'tail');
|
||||
lines.forEach((ln, i) => pre.append(el('span', i === lines.length - 1 ? 'now' : null, ln + '\n')));
|
||||
return pre;
|
||||
}
|
||||
|
||||
F.projects.forEach((p) => {
|
||||
const head = el('div', 'section-head');
|
||||
head.append(
|
||||
el('h2', null, p.name),
|
||||
el('span', 'meta', p.worktrees.length + ' worktrees · ' + p.agents.length + ' agents'),
|
||||
el('span', 'rule'),
|
||||
);
|
||||
board.append(head);
|
||||
|
||||
const grid = el('div', 'grid');
|
||||
// Reading order: worktrees that need you first, then alphabetical — stable.
|
||||
const wts = p.worktrees.slice().sort((a, b) => {
|
||||
const ra = F.severityRank(a.worstState);
|
||||
const rb = F.severityRank(b.worstState);
|
||||
const na = ra <= 2 ? 0 : 1;
|
||||
const nb = rb <= 2 ? 0 : 1;
|
||||
return na - nb || a.worktree.localeCompare(b.worktree);
|
||||
});
|
||||
|
||||
wts.forEach((g) => {
|
||||
const card = el('article', 'wt');
|
||||
card.dataset.worst = g.worstState;
|
||||
card.dataset.search = (g.worktree + ' ' + g.agents.map((a) => a.name).join(' ')).toLowerCase();
|
||||
|
||||
const head = el('div', 'wt-head');
|
||||
head.append(el('span', 'wt-name', g.worktree));
|
||||
if (g.unread) head.append(el('i', 'unread-dot'));
|
||||
head.append(el('span', 'wt-count', String(g.agents.length)));
|
||||
card.append(head);
|
||||
|
||||
// Compact density: agents collapse to a dot cluster in the header line.
|
||||
const dots = el('div', 'dots-row');
|
||||
g.agents.forEach((a) => {
|
||||
const d = el('i', 'dot');
|
||||
d.dataset.state = a.state;
|
||||
dots.append(d);
|
||||
});
|
||||
card.append(dots);
|
||||
|
||||
g.agents.forEach((a) => {
|
||||
const row = el('div', 'agent');
|
||||
if (a.bucket === 'needsYou') row.classList.add('is-loud');
|
||||
const dot = el('i', 'dot');
|
||||
dot.dataset.state = a.state;
|
||||
row.append(dot, el('span', 'agent-name', a.shortName));
|
||||
if (a.pr) row.append(el('span', 'agent-pr', '#' + a.pr.number));
|
||||
row.append(el('span', 'agent-age', F.ageLabel(a.state === 'done' || a.state === 'review' ? a.durationMs : a.startedAgo)));
|
||||
row.addEventListener('click', () => select(a, row));
|
||||
card.append(row);
|
||||
agentRows.set(a.id, row);
|
||||
|
||||
// The line that needs you, always visible at normal density.
|
||||
if (a.state === 'attention') card.append(el('p', 'agent-ask', a.question.text));
|
||||
else if (a.state === 'permission') card.append(el('p', 'agent-ask', 'Waiting for permission: ' + '$ ' + (F.decisions.find((d) => d.key === a.decisionKey) || {}).command));
|
||||
else if (a.state === 'failed') card.append(el('p', 'agent-ask', a.failure));
|
||||
|
||||
card.append(tailBlock(a, F.fakeTail(a).slice(-4)));
|
||||
});
|
||||
|
||||
grid.append(card);
|
||||
});
|
||||
board.append(grid);
|
||||
});
|
||||
|
||||
// ---- density --------------------------------------------------------------
|
||||
const densityBar = document.getElementById('density');
|
||||
function setDensity(d) {
|
||||
board.dataset.density = d;
|
||||
densityBar.querySelectorAll('button').forEach((b) => b.classList.toggle('is-active', b.dataset.d === d));
|
||||
}
|
||||
densityBar.addEventListener('click', (e) => {
|
||||
if (e.target.dataset.d) setDensity(e.target.dataset.d);
|
||||
});
|
||||
const ORDER = ['compact', 'normal', 'detail'];
|
||||
window.addEventListener('wheel', (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
const i = ORDER.indexOf(board.dataset.density) + (e.deltaY < 0 ? 1 : -1);
|
||||
if (ORDER[i]) setDensity(ORDER[i]);
|
||||
}, { passive: false });
|
||||
|
||||
// ---- respond panel --------------------------------------------------------
|
||||
const panel = document.getElementById('panel');
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
document.body.classList.remove('panel-open');
|
||||
if (selected) agentRows.get(selected.id)?.classList.remove('is-selected');
|
||||
selected = null;
|
||||
}
|
||||
function select(a, row) {
|
||||
if (selected) agentRows.get(selected.id)?.classList.remove('is-selected');
|
||||
selected = a;
|
||||
row.classList.add('is-selected');
|
||||
panel.hidden = false;
|
||||
document.body.classList.add('panel-open');
|
||||
panel.replaceChildren();
|
||||
|
||||
const close = el('button', 'close', '✕');
|
||||
close.addEventListener('click', closePanel);
|
||||
panel.append(close, el('h1', null, a.shortName));
|
||||
|
||||
const sl = el('div', 'state-line');
|
||||
const d = el('i', 'dot');
|
||||
d.dataset.state = a.state;
|
||||
sl.append(d, el('span', null, STATE_LABEL[a.state] + ' · ' + (a.alive ? 'process live' : 'reply restarts it')));
|
||||
panel.append(sl, el('p', 'path', a.displayProject + ' / ' + a.worktree));
|
||||
panel.append(tailBlock(a, F.fakeTail(a).slice(-8)));
|
||||
|
||||
if (a.state === 'attention') {
|
||||
panel.append(el('p', 'question', a.question.text));
|
||||
const opts = el('div', 'opts');
|
||||
a.question.options.forEach((opt, i) => {
|
||||
const b = el('button');
|
||||
b.append(el('kbd', null, String(i + 1)), document.createTextNode(opt));
|
||||
opts.append(b);
|
||||
});
|
||||
panel.append(opts);
|
||||
} else if (a.state === 'permission') {
|
||||
const dec = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
panel.append(el('p', 'question', 'Allow ' + dec.command + '? ' + dec.agents.length + ' agents are waiting on this decision.'));
|
||||
const actions = el('div', 'actions');
|
||||
const allow = el('button', 'primary', 'Allow — resumes ' + dec.agents.length);
|
||||
actions.append(allow, el('button', null, 'Deny'));
|
||||
panel.append(actions);
|
||||
} else if (a.state === 'failed') {
|
||||
panel.append(el('p', 'question', a.failure));
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Retry'), el('button', null, 'Open terminal'));
|
||||
panel.append(actions);
|
||||
} else if (a.state === 'review') {
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Review diff'), el('button', null, 'Mark reviewed'));
|
||||
panel.append(actions);
|
||||
}
|
||||
const reply = el('div', 'reply');
|
||||
const input = Object.assign(el('input'), {
|
||||
placeholder: a.state === 'working' ? 'Queue a message…' : 'Reply…',
|
||||
});
|
||||
reply.append(input);
|
||||
panel.append(reply);
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closePanel();
|
||||
});
|
||||
|
||||
// ---- search ---------------------------------------------------------------
|
||||
const search = document.getElementById('search');
|
||||
search.addEventListener('input', () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
document.querySelectorAll('.wt').forEach((card) => {
|
||||
card.classList.toggle('is-dim', !!q && !card.dataset.search.includes(q));
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((MAC ? e.metaKey : e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
search.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- footer ---------------------------------------------------------------
|
||||
const whisper = document.getElementById('whisper');
|
||||
[
|
||||
['var(--state-attention)', 'needs you'],
|
||||
['color-mix(in srgb, var(--state-working) 75%, transparent)', 'working'],
|
||||
['color-mix(in srgb, var(--state-done) 85%, transparent)', 'review'],
|
||||
['var(--state-idle)', 'done'],
|
||||
].forEach(([color, label]) => {
|
||||
const s = el('span');
|
||||
const i = el('i');
|
||||
i.style.background = color;
|
||||
s.append(i, el('span', null, label));
|
||||
whisper.append(s);
|
||||
});
|
||||
whisper.append(el('span', null, (MAC ? '⌃' : 'Ctrl+') + 'scroll = density · captured ' +
|
||||
new Date(F.now).toISOString().slice(11, 16) + ' UTC'));
|
||||
|
||||
// ---- pre-seed: select the first attention agent in reading order ----------
|
||||
const seed = F.projects.flatMap((p) => p.agents).find((a) => a.state === 'attention');
|
||||
if (seed) select(seed, agentRows.get(seed.id));
|
||||
|
||||
const wanted = new URLSearchParams(location.search).get('d');
|
||||
if (ORDER.includes(wanted)) setDensity(wanted);
|
||||
})();
|
||||
|
After Width: | Height: | Size: 210 KiB |
@@ -0,0 +1,813 @@
|
||||
/* Fleet Canvas — an infinite pan/zoom space where each repo is a concentric system:
|
||||
repo core → worktree orbits → agent nodes. Zoom is altitude: the further out you
|
||||
are, the fewer words render, so labels are never unreadable mush. */
|
||||
|
||||
:root {
|
||||
--dim: 0.15;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- top bar (hexmap language) --------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tabs a {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tabs a.is-active {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chip .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chip .n {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip .lbl {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip.is-loud .dot {
|
||||
opacity: 1;
|
||||
box-shadow: var(--glow-attention);
|
||||
}
|
||||
|
||||
.chip.is-loud .n {
|
||||
color: var(--state-attention);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chips .sep {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topright {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 170px;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.search::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accounting {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- stage ----------------------------------------------------------------- */
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.stage.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
user-select: none;
|
||||
/* Counter-scale factor for chrome text: 1 / zoom, so labels hold a constant
|
||||
screen size while the map grows around them. Node glyphs deliberately do
|
||||
NOT use it — they are contents of a node and scale with it. */
|
||||
--k: 1;
|
||||
}
|
||||
|
||||
.grid-dot {
|
||||
fill: var(--border);
|
||||
}
|
||||
|
||||
.orbit {
|
||||
fill: none;
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- repo core ------------------------------------------------------------- */
|
||||
|
||||
.core {
|
||||
fill: var(--card);
|
||||
stroke: var(--input);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.core-pip {
|
||||
fill: var(--muted-foreground);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.repo-name {
|
||||
font-size: calc(var(--k) * 14px);
|
||||
font-weight: 600;
|
||||
fill: var(--foreground);
|
||||
text-anchor: middle;
|
||||
letter-spacing: 0.01em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.repo-sub {
|
||||
font-size: calc(var(--k) * 9.5px);
|
||||
fill: var(--muted-foreground);
|
||||
text-anchor: middle;
|
||||
opacity: 0;
|
||||
transition: opacity 130ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- worktree cluster ------------------------------------------------------ */
|
||||
|
||||
.wt-label {
|
||||
font-size: calc(var(--k) * 10px);
|
||||
fill: var(--muted-foreground);
|
||||
text-anchor: middle;
|
||||
opacity: 0;
|
||||
transition: opacity 130ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wt-unread {
|
||||
fill: color-mix(in srgb, var(--foreground) 72%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cluster.is-dim .wt-label,
|
||||
.cluster.is-dim .wt-unread {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---- agent node ------------------------------------------------------------ */
|
||||
|
||||
.node {
|
||||
--hue: var(--state-idle);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.node.is-dim {
|
||||
opacity: var(--dim);
|
||||
}
|
||||
|
||||
.n-core {
|
||||
fill: color-mix(in srgb, var(--hue) 16%, var(--card));
|
||||
stroke: var(--hue);
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
/* Liveness is its own channel: an exited process draws hollow. */
|
||||
.node.is-dead .n-core {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
.n-glyph {
|
||||
font-size: 9px;
|
||||
fill: var(--hue);
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
opacity: 0;
|
||||
transition: opacity 130ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.n-cap {
|
||||
font-size: calc(var(--k) * 11px);
|
||||
fill: var(--muted-foreground);
|
||||
opacity: 0;
|
||||
transition: opacity 130ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.n-sel {
|
||||
fill: none;
|
||||
stroke: var(--ring);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node:hover .n-sel,
|
||||
.node:focus-visible .n-sel {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.node.is-selected .n-sel {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* -- per state --
|
||||
SVG strokes can't be driven by the shared border-color keyframes, so these
|
||||
mirror them exactly: same stops, same steps(1, end), same duration tokens. */
|
||||
|
||||
@keyframes blink-node-attention {
|
||||
0%,
|
||||
44% {
|
||||
stroke: var(--state-attention);
|
||||
filter: drop-shadow(var(--glow-attention));
|
||||
}
|
||||
50%,
|
||||
94% {
|
||||
stroke: var(--muted-foreground);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-node-blocked {
|
||||
0%,
|
||||
44% {
|
||||
stroke: var(--state-blocked);
|
||||
filter: drop-shadow(0 0 7px color-mix(in srgb, var(--state-blocked) 60%, transparent));
|
||||
}
|
||||
50%,
|
||||
94% {
|
||||
stroke: var(--muted-foreground);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-node-review {
|
||||
0%,
|
||||
100% {
|
||||
stroke: color-mix(in srgb, var(--state-done) 70%, var(--border));
|
||||
}
|
||||
50% {
|
||||
stroke: color-mix(in srgb, var(--state-done) 20%, var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
.node[data-state='attention'],
|
||||
.node[data-state='permission'] {
|
||||
--hue: var(--state-attention);
|
||||
}
|
||||
|
||||
.node[data-state='attention'] .n-core,
|
||||
.node[data-state='permission'] .n-core {
|
||||
animation: blink-node-attention var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.node[data-state='failed'] {
|
||||
--hue: var(--state-blocked);
|
||||
}
|
||||
|
||||
.node[data-state='failed'] .n-core {
|
||||
animation: blink-node-blocked var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.node[data-state='working'] {
|
||||
--hue: var(--state-working);
|
||||
}
|
||||
|
||||
.node[data-state='working'] .n-core {
|
||||
stroke: color-mix(in srgb, var(--state-working) 50%, var(--card));
|
||||
}
|
||||
|
||||
/* Full-circle geometry with a dash gap: the bbox stays centred on the node, so
|
||||
fill-box rotation spins true. */
|
||||
.n-spin {
|
||||
fill: none;
|
||||
stroke: var(--state-working);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
opacity: 0.6;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: spin calc(var(--pulse-slow) * 1.7) linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.node.is-dead .n-spin,
|
||||
.node.is-sleeping .n-spin {
|
||||
animation: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.node[data-state='review'] {
|
||||
--hue: var(--state-done);
|
||||
}
|
||||
|
||||
.node[data-state='review'] .n-core {
|
||||
animation: pulse-node-review var(--pulse-slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node[data-state='done'] .n-core {
|
||||
fill: color-mix(in srgb, var(--state-done) 7%, var(--card));
|
||||
}
|
||||
|
||||
/* ---- zoom as altitude ------------------------------------------------------
|
||||
far (<0.7) nodes + repo names only
|
||||
mid (<1.6) worktree names for anything not settled, or on hover
|
||||
near (≥1.6) every worktree name, node glyphs, needs-you captions */
|
||||
|
||||
.canvas[data-zoom='mid'] .repo-sub,
|
||||
.canvas[data-zoom='near'] .repo-sub {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.canvas[data-zoom='mid'] .cluster:not(.is-quiet) .wt-label,
|
||||
.canvas[data-zoom='near'] .wt-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Thinned at build time so mid-band labels never overlap; hover overrides. */
|
||||
.canvas[data-zoom='mid'] .cluster.is-crowded .wt-label {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.canvas[data-zoom='mid'] .cluster:hover .wt-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.canvas[data-zoom='near'] .n-glyph {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.canvas[data-zoom='near'] .node[data-state='review'] .n-glyph {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.canvas[data-zoom='near'] .n-cap {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.canvas[data-zoom='mid'] .cluster.is-dim .wt-label,
|
||||
.canvas[data-zoom='near'] .cluster.is-dim .wt-label {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---- lineage --------------------------------------------------------------- */
|
||||
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-end {
|
||||
fill: var(--muted-foreground);
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- viewport tools -------------------------------------------------------- */
|
||||
|
||||
.viewtools {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vt {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted-foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vt:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 44%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- footer whisper -------------------------------------------------------- */
|
||||
|
||||
.whisper {
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 22px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.whisper .key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.whisper .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.whisper .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ---- tooltip --------------------------------------------------------------- */
|
||||
|
||||
.tip {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
max-width: 280px;
|
||||
padding: 9px 11px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-floating);
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tip .t-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.tip .t-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.tip .t-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta {
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta .mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- selection panel ------------------------------------------------------- */
|
||||
|
||||
/* Spans the stage, not the window: the accounting whisper and the legend stay
|
||||
readable while the panel is open, and the canvas underneath never reflows. */
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 46px;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
z-index: 50;
|
||||
width: 370px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-floating);
|
||||
transform: translateX(101%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 190ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
visibility 190ms;
|
||||
}
|
||||
|
||||
.panel.is-open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 22px 8px;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-name {
|
||||
margin: 0 30px 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.p-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.p-path {
|
||||
margin: 8px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.p-facts {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
margin: 16px 0 14px;
|
||||
}
|
||||
|
||||
.p-facts .v {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.p-tail {
|
||||
padding: 9px 11px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-tail > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.p-tail .last {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-respond {
|
||||
padding: 14px 22px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.p-ask {
|
||||
margin: 0 0 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.p-cmd {
|
||||
margin: 0 0 11px;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-fail {
|
||||
margin: 0 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
|
||||
.opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
background: var(--secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt:hover {
|
||||
border-color: var(--input);
|
||||
}
|
||||
|
||||
.opt .num {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.reply input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reply input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply input:focus {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.btnrow {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.btn.is-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.btn.is-primary:hover {
|
||||
background: var(--foreground);
|
||||
}
|
||||
|
||||
.btn.is-danger:hover {
|
||||
border-color: color-mix(in srgb, var(--state-blocked) 55%, var(--border));
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Fleet — Canvas</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-canvas.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<span class="brand">Fleet</span>
|
||||
<nav class="tabs" aria-label="Fleet views">
|
||||
<a href="./fleet-map.html">Map</a>
|
||||
<a href="./fleet-strips.html">Strips</a>
|
||||
<a href="./mission-control.html">Mission Control</a>
|
||||
<a href="./fleet-hexmap.html">Hexmap</a>
|
||||
<a href="./fleet-terminal-wall.html">Terminal Wall</a>
|
||||
<a href="./fleet-triage.html">Triage</a>
|
||||
<a class="is-active" href="./fleet-canvas.html" aria-current="page">Canvas</a>
|
||||
</nav>
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="topright">
|
||||
<input
|
||||
id="search"
|
||||
class="search"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filter the fleet"
|
||||
/>
|
||||
<span class="accounting" id="accounting"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stage" id="stage">
|
||||
<svg class="canvas" id="canvas" data-zoom="mid" aria-label="Fleet canvas">
|
||||
<defs>
|
||||
<pattern id="dotgrid" width="44" height="44" patternUnits="userSpaceOnUse">
|
||||
<circle class="grid-dot" cx="1" cy="1" r="1" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<g id="world">
|
||||
<rect class="grid" x="-5000" y="-5000" width="10000" height="10000" fill="url(#dotgrid)" />
|
||||
<g id="orbits"></g>
|
||||
<g id="lineage"></g>
|
||||
<g id="systems"></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<p class="empty" id="empty" hidden></p>
|
||||
|
||||
<div class="viewtools">
|
||||
<button class="vt" id="fit" type="button">Fit</button>
|
||||
<button class="vt" id="zoomreset" type="button" aria-label="Reset zoom to 100%">100%</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="whisper" id="whisper"></footer>
|
||||
</main>
|
||||
|
||||
<aside class="panel" id="panel" aria-label="Agent detail" aria-hidden="true"></aside>
|
||||
<div class="tip" id="tip" role="tooltip" hidden></div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-canvas.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,752 @@
|
||||
/* Fleet Canvas — a revival of the movable concentric dashboard: repos are systems
|
||||
(core → worktree orbits → agent nodes) on one infinite pan/zoom surface.
|
||||
Placement is a pure function of (project, worktree size, name hash), so the map is
|
||||
byte-identical on every load and users can build spatial memory. Zoom is altitude:
|
||||
text appears band by band instead of scaling into mush. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const F = window.FLEET;
|
||||
if (!F) throw new Error('fleet-state-synthesis.js must be loaded first');
|
||||
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
const IS_MAC = navigator.userAgent.includes('Mac');
|
||||
|
||||
// Clamp zoom so we never invert the content or lose the fleet context.
|
||||
const MIN_SCALE = 0.3;
|
||||
const MAX_SCALE = 4;
|
||||
// Pixels a mousedown may travel before it becomes a pan instead of a click.
|
||||
const DRAG_THRESHOLD_PX = 3;
|
||||
const FIT_PAD = 40;
|
||||
// System sizes are tuned so fit-all lands near 0.8 on a normal window; the floor
|
||||
// only bites on a very short one, where holding the label band beats a silent map.
|
||||
const SEED_FLOOR = 0.7;
|
||||
const CAPTION_CHARS = 34;
|
||||
// Labels counter-scale, so their width in world units peaks at the bottom of the
|
||||
// label band; 20 chars is what keeps them from colliding there.
|
||||
const WT_LABEL_CHARS = 20;
|
||||
|
||||
// World coordinates are fixed per repo: orca large and center-left, orca-mobile
|
||||
// right, automation upper-left. Orbit radii carry the size of each system.
|
||||
const SYSTEMS = {
|
||||
orca: { cx: 0, cy: 0, core: 26, orbits: [145, 235, 325, 415] },
|
||||
'orca-mobile': { cx: 830, cy: 115, core: 20, orbits: [115, 200, 280] },
|
||||
automation: { cx: -570, cy: -425, core: 15, orbits: [90, 150] },
|
||||
};
|
||||
|
||||
const STATE_LABEL = {
|
||||
attention: 'Waiting on your answer',
|
||||
permission: 'Waiting on permission',
|
||||
failed: 'Failed',
|
||||
working: 'Working',
|
||||
review: 'Ready to review',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
const el = {
|
||||
stage: document.getElementById('stage'),
|
||||
canvas: document.getElementById('canvas'),
|
||||
world: document.getElementById('world'),
|
||||
orbits: document.getElementById('orbits'),
|
||||
lineage: document.getElementById('lineage'),
|
||||
systems: document.getElementById('systems'),
|
||||
chips: document.getElementById('chips'),
|
||||
accounting: document.getElementById('accounting'),
|
||||
whisper: document.getElementById('whisper'),
|
||||
search: document.getElementById('search'),
|
||||
empty: document.getElementById('empty'),
|
||||
panel: document.getElementById('panel'),
|
||||
tip: document.getElementById('tip'),
|
||||
fit: document.getElementById('fit'),
|
||||
zoomreset: document.getElementById('zoomreset'),
|
||||
};
|
||||
|
||||
const byId = new Map(F.agents.map((a) => [a.id, a]));
|
||||
const childrenOf = new Map();
|
||||
for (const a of F.agents) {
|
||||
if (a.parent && byId.has(a.parent)) {
|
||||
if (!childrenOf.has(a.parent)) childrenOf.set(a.parent, []);
|
||||
childrenOf.get(a.parent).push(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c],
|
||||
);
|
||||
const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
|
||||
const clock = (ms) =>
|
||||
new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const liveness = (a) =>
|
||||
a.loopSleeping ? 'loop sleeping' : a.alive ? 'process live' : 'process exited';
|
||||
// Same FNV-1a the synthesis uses, projected to [0,1) for angles and phases.
|
||||
const rnd = (key) => (F.hash(key) % 100000) / 100000;
|
||||
|
||||
// ---- layout ----------------------------------------------------------------
|
||||
|
||||
// Node size restates the original's duration idea: log scale, clamped at 30s and
|
||||
// 24h so a six-day loop doesn't flatten everything else.
|
||||
const LOG_LO = Math.log(30 * 1000);
|
||||
const LOG_HI = Math.log(24 * 3600 * 1000);
|
||||
function nodeRadius(a) {
|
||||
const t = (Math.log(Math.max(a.durationMs, 1)) - LOG_LO) / (LOG_HI - LOG_LO);
|
||||
return 7 + 4 * Math.max(0, Math.min(1, t));
|
||||
}
|
||||
|
||||
// Worktrees per orbit, proportional to circumference (largest remainder), so the
|
||||
// outer rings — which have the arc length for it — take the crowded ones.
|
||||
function ringCapacities(total, orbits) {
|
||||
const sum = orbits.reduce((s, r) => s + r, 0);
|
||||
const raw = orbits.map((r) => (total * r) / sum);
|
||||
const caps = raw.map((v) => Math.floor(v));
|
||||
let left = total - caps.reduce((s, c) => s + c, 0);
|
||||
const order = caps.map((_, i) => i).sort((a, b) => raw[b] - caps[b] - (raw[a] - caps[a]));
|
||||
for (let i = 0; left > 0; i++, left--) caps[order[i % caps.length]]++;
|
||||
return caps;
|
||||
}
|
||||
|
||||
// A worktree's agents pack into one tight cluster: a single node sits dead centre,
|
||||
// 2–7 form a ring, 8+ keep one in the middle. Spacing beats the largest node.
|
||||
function packCluster(n, phase) {
|
||||
if (n === 1) return [{ x: 0, y: 0 }];
|
||||
const outer = n <= 7 ? n : n - 1;
|
||||
const radius = 14 + outer * 1.75;
|
||||
const pts = n > 7 ? [{ x: 0, y: 0 }] : [];
|
||||
for (let i = 0; i < outer; i++) {
|
||||
const th = phase + (i * Math.PI * 2) / outer;
|
||||
pts.push({ x: radius * Math.cos(th), y: radius * Math.sin(th) });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
const systems = [];
|
||||
const posOf = new Map();
|
||||
const bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
||||
|
||||
function grow(x, y, pad) {
|
||||
bounds.minX = Math.min(bounds.minX, x - pad);
|
||||
bounds.minY = Math.min(bounds.minY, y - pad);
|
||||
bounds.maxX = Math.max(bounds.maxX, x + pad);
|
||||
bounds.maxY = Math.max(bounds.maxY, y + pad);
|
||||
}
|
||||
|
||||
for (const p of F.projects) {
|
||||
const cfg = SYSTEMS[p.name];
|
||||
if (!cfg) continue;
|
||||
// Stable sort on a list already alphabetised by the synthesis: equal-size
|
||||
// worktrees stay in name order, so ring membership never wobbles.
|
||||
const wts = p.worktrees.slice().sort((a, b) => a.agents.length - b.agents.length);
|
||||
const caps = ringCapacities(wts.length, cfg.orbits);
|
||||
const sys = { name: p.name, project: p, cx: cfg.cx, cy: cfg.cy, core: cfg.core, orbits: cfg.orbits, clusters: [] };
|
||||
let idx = 0;
|
||||
|
||||
cfg.orbits.forEach((radius, ring) => {
|
||||
const k = caps[ring];
|
||||
if (!k) return;
|
||||
const slot = (Math.PI * 2) / k;
|
||||
const phase = rnd(p.name + '#orbit' + ring) * Math.PI * 2;
|
||||
for (let j = 0; j < k; j++, idx++) {
|
||||
const wt = wts[idx];
|
||||
const key = p.name + '/' + wt.worktree;
|
||||
// Even slots keep clusters from colliding; hash jitter inside half a slot
|
||||
// keeps the ring from reading as a clock face.
|
||||
const th = phase + j * slot + (rnd(key) - 0.5) * slot * 0.5;
|
||||
const cx = cfg.cx + radius * Math.cos(th);
|
||||
const cy = cfg.cy + radius * Math.sin(th);
|
||||
const pts = packCluster(wt.agents.length, rnd(key + ':pack') * Math.PI * 2);
|
||||
let extent = 0;
|
||||
const nodes = wt.agents.map((a, i) => {
|
||||
const r = nodeRadius(a);
|
||||
const node = { a, x: cx + pts[i].x, y: cy + pts[i].y, r };
|
||||
posOf.set(a.id, { x: node.x, y: node.y });
|
||||
extent = Math.max(extent, Math.hypot(pts[i].x, pts[i].y) + r);
|
||||
return node;
|
||||
});
|
||||
sys.clusters.push({ wt, key, x: cx, y: cy, extent, nodes });
|
||||
grow(cx, cy, extent + 26);
|
||||
}
|
||||
});
|
||||
|
||||
grow(cfg.cx, cfg.cy, cfg.core + 30);
|
||||
systems.push(sys);
|
||||
}
|
||||
|
||||
// Greedy label thinning, the way map renderers do it: walk the worktrees worst
|
||||
// state first and drop any label that would collide with one already placed.
|
||||
// Measured at the bottom of the label band, where counter-scaled text is widest
|
||||
// in world units — by the near band nothing collides, so nothing is thinned.
|
||||
(function thinLabels() {
|
||||
const BAND_FLOOR = 0.7;
|
||||
const CHAR_W = 5.6;
|
||||
const LINE_H = 13;
|
||||
const placed = [];
|
||||
const cands = [];
|
||||
for (const sys of systems) {
|
||||
for (const cl of sys.clusters) if (cl.wt.worstState !== 'done') cands.push(cl);
|
||||
}
|
||||
cands.sort(
|
||||
(a, b) =>
|
||||
F.severityRank(a.wt.worstState) - F.severityRank(b.wt.worstState) ||
|
||||
a.key.localeCompare(b.key),
|
||||
);
|
||||
for (const cl of cands) {
|
||||
const w = (Math.min(cl.wt.worktree.length, WT_LABEL_CHARS) * CHAR_W) / BAND_FLOOR;
|
||||
const h = LINE_H / BAND_FLOOR;
|
||||
const y = cl.y + cl.extent + 12;
|
||||
const box = { x0: cl.x - w / 2, x1: cl.x + w / 2, y0: y - h, y1: y };
|
||||
if (placed.some((b) => box.x0 < b.x1 && b.x0 < box.x1 && box.y0 < b.y1 && b.y0 < box.y1)) {
|
||||
cl.crowded = true;
|
||||
} else {
|
||||
placed.push(box);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// ---- chrome ----------------------------------------------------------------
|
||||
|
||||
function renderChrome() {
|
||||
const c = F.counts;
|
||||
const chips = [
|
||||
{ n: c.needsYou, lbl: 'need you', hue: 'var(--state-attention)', loud: true },
|
||||
{ n: c.working, lbl: 'working', hue: 'var(--state-working)' },
|
||||
{ n: c.review, lbl: 'to review', hue: 'var(--state-done)' },
|
||||
{ n: c.done, lbl: 'done', hue: 'var(--state-idle)' },
|
||||
];
|
||||
el.chips.innerHTML = chips
|
||||
.map(
|
||||
(x) =>
|
||||
'<span class="chip' + (x.loud ? ' is-loud' : '') + '" style="--hue:' + x.hue + '">' +
|
||||
'<i class="dot"></i><span class="n">' + x.n + '</span>' +
|
||||
'<span class="lbl">' + x.lbl + '</span></span>',
|
||||
)
|
||||
.join('<span class="sep">·</span>');
|
||||
|
||||
el.search.placeholder = 'Filter… ' + (IS_MAC ? '⌘K' : 'Ctrl+K');
|
||||
el.accounting.textContent =
|
||||
c.total + ' / ' + c.fleetTotal + ' agents · ' +
|
||||
F.byWorktree.length + ' / ' + c.fleetWorktrees + ' worktrees';
|
||||
|
||||
const keys = [
|
||||
['var(--state-attention)', 'amber = needs you'],
|
||||
['var(--state-working)', 'gold = working'],
|
||||
['var(--state-done)', 'green = review'],
|
||||
['var(--state-idle)', 'dark = done'],
|
||||
];
|
||||
el.whisper.innerHTML =
|
||||
keys
|
||||
.map((k) => '<span class="key"><i class="dot" style="--hue:' + k[0] + '"></i>' + k[1] + '</span>')
|
||||
.join('<span class="sep">·</span>') +
|
||||
'<span class="sep">·</span><span>scroll to zoom · drag to pan</span>';
|
||||
}
|
||||
|
||||
// ---- canvas ----------------------------------------------------------------
|
||||
|
||||
const nodeOf = new Map();
|
||||
|
||||
function svg(tag, attrs) {
|
||||
const n = document.createElementNS(SVGNS, tag);
|
||||
for (const k in attrs) n.setAttribute(k, attrs[k]);
|
||||
return n;
|
||||
}
|
||||
|
||||
function captionFor(a) {
|
||||
if (a.state === 'attention') return clip(a.question.text, CAPTION_CHARS);
|
||||
if (a.state === 'permission') {
|
||||
const d = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
return d ? clip('$ ' + d.command, CAPTION_CHARS) : '';
|
||||
}
|
||||
if (a.state === 'failed') return clip(a.failure, CAPTION_CHARS);
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderNode(n) {
|
||||
const a = n.a;
|
||||
const g = svg('g', {
|
||||
class: 'node' + (a.alive ? '' : ' is-dead') + (a.loopSleeping ? ' is-sleeping' : ''),
|
||||
transform: 'translate(' + n.x.toFixed(2) + ',' + n.y.toFixed(2) + ')',
|
||||
'data-agent': a.id,
|
||||
'data-state': a.state,
|
||||
tabindex: '0',
|
||||
role: 'button',
|
||||
'aria-label':
|
||||
a.shortName + ' — ' + STATE_LABEL[a.state] + ', ' + a.displayProject + '/' + a.worktree,
|
||||
});
|
||||
g.appendChild(svg('circle', { class: 'n-sel', r: (n.r + 3.5).toFixed(2) }));
|
||||
g.appendChild(svg('circle', { class: 'n-core', r: n.r.toFixed(2) }));
|
||||
|
||||
if (a.state === 'working') {
|
||||
const sr = n.r + 4;
|
||||
const circ = 2 * Math.PI * sr;
|
||||
g.appendChild(
|
||||
svg('circle', {
|
||||
class: 'n-spin',
|
||||
r: sr.toFixed(2),
|
||||
'stroke-dasharray': (circ * 0.22).toFixed(2) + ' ' + (circ * 0.78).toFixed(2),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const glyph = svg('text', { class: 'n-glyph' });
|
||||
glyph.textContent = F.stateGlyph(a.state);
|
||||
g.appendChild(glyph);
|
||||
}
|
||||
|
||||
const cap = captionFor(a);
|
||||
if (cap) {
|
||||
const t = svg('text', { class: 'n-cap', x: (n.r + 7).toFixed(2), y: '3.5' });
|
||||
t.textContent = cap;
|
||||
g.appendChild(t);
|
||||
}
|
||||
|
||||
nodeOf.set(a.id, g);
|
||||
return g;
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
for (const sys of systems) {
|
||||
for (const radius of sys.orbits) {
|
||||
el.orbits.appendChild(svg('circle', { class: 'orbit', cx: sys.cx, cy: sys.cy, r: radius }));
|
||||
}
|
||||
|
||||
const sg = svg('g', { class: 'system' });
|
||||
sg.appendChild(svg('circle', { class: 'core', cx: sys.cx, cy: sys.cy, r: sys.core }));
|
||||
sg.appendChild(svg('circle', { class: 'core-pip', cx: sys.cx, cy: sys.cy, r: 2.5 }));
|
||||
|
||||
const name = svg('text', { class: 'repo-name', x: sys.cx, y: sys.cy + sys.core + 20 });
|
||||
name.textContent = sys.name;
|
||||
sg.appendChild(name);
|
||||
|
||||
const sub = svg('text', { class: 'repo-sub', x: sys.cx, y: sys.cy + sys.core + 34 });
|
||||
sub.textContent = sys.project.worktrees.length + ' worktrees · ' + sys.project.agents.length + ' agents';
|
||||
sg.appendChild(sub);
|
||||
|
||||
for (const cl of sys.clusters) {
|
||||
const cg = svg('g', {
|
||||
class:
|
||||
'cluster' +
|
||||
(cl.wt.worstState === 'done' ? ' is-quiet' : '') +
|
||||
(cl.crowded ? ' is-crowded' : ''),
|
||||
'data-cluster': cl.key,
|
||||
});
|
||||
for (const n of cl.nodes) cg.appendChild(renderNode(n));
|
||||
|
||||
const lbl = svg('text', {
|
||||
class: 'wt-label',
|
||||
x: cl.x.toFixed(2),
|
||||
y: (cl.y + cl.extent + 12).toFixed(2),
|
||||
});
|
||||
lbl.textContent = clip(cl.wt.worktree, WT_LABEL_CHARS);
|
||||
cg.appendChild(lbl);
|
||||
|
||||
if (cl.wt.unread) {
|
||||
cg.appendChild(
|
||||
svg('circle', {
|
||||
class: 'wt-unread',
|
||||
cx: (cl.x + cl.extent + 6).toFixed(2),
|
||||
cy: (cl.y - cl.extent * 0.55).toFixed(2),
|
||||
r: '1.5',
|
||||
}),
|
||||
);
|
||||
}
|
||||
sg.appendChild(cg);
|
||||
}
|
||||
el.systems.appendChild(sg);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- transform -------------------------------------------------------------
|
||||
|
||||
// Module-level so pan/zoom survives a remount, exactly as the original did.
|
||||
let tf = { x: 0, y: 0, scale: 1 };
|
||||
let band = '';
|
||||
|
||||
function applyTransform() {
|
||||
el.world.setAttribute(
|
||||
'transform',
|
||||
'translate(' + tf.x.toFixed(2) + ',' + tf.y.toFixed(2) + ') scale(' + tf.scale.toFixed(4) + ')',
|
||||
);
|
||||
el.canvas.style.setProperty('--k', String(1 / tf.scale));
|
||||
const next = tf.scale < 0.7 ? 'far' : tf.scale < 1.6 ? 'mid' : 'near';
|
||||
if (next !== band) {
|
||||
band = next;
|
||||
el.canvas.dataset.zoom = next;
|
||||
}
|
||||
el.zoomreset.textContent = Math.round(tf.scale * 100) + '%';
|
||||
}
|
||||
|
||||
function fitAll(floor) {
|
||||
const r = el.stage.getBoundingClientRect();
|
||||
const w = bounds.maxX - bounds.minX;
|
||||
const h = bounds.maxY - bounds.minY;
|
||||
let s = Math.min((r.width - FIT_PAD * 2) / w, (r.height - FIT_PAD * 2) / h);
|
||||
s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, s));
|
||||
if (floor) s = Math.min(MAX_SCALE, Math.max(s, floor));
|
||||
tf = {
|
||||
x: r.width / 2 - (bounds.minX + w / 2) * s,
|
||||
y: r.height / 2 - (bounds.minY + h / 2) * s,
|
||||
scale: s,
|
||||
};
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function zoomAt(clientX, clientY, factor) {
|
||||
const r = el.stage.getBoundingClientRect();
|
||||
const mx = clientX - r.left;
|
||||
const my = clientY - r.top;
|
||||
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, tf.scale * factor));
|
||||
const actual = next / tf.scale;
|
||||
// Keep the point under the cursor stationary in screen space.
|
||||
tf = { x: mx - (mx - tf.x) * actual, y: my - (my - tf.y) * actual, scale: next };
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
// ---- tooltip ---------------------------------------------------------------
|
||||
|
||||
function showTip(node, a) {
|
||||
el.tip.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')');
|
||||
el.tip.innerHTML =
|
||||
'<div class="t-name">' + esc(a.shortName) + '</div>' +
|
||||
'<div class="t-state">' + STATE_LABEL[a.state] +
|
||||
'<span class="live">· ' + liveness(a) + '</span></div>' +
|
||||
'<div class="t-meta"><span class="mono">' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '</span><br />' +
|
||||
'started ' + F.ageLabel(a.startedAgo) + ' ago · ran ' + F.ageLabel(a.durationMs) +
|
||||
'<br />' + esc(a.detail || '—') + '</div>';
|
||||
el.tip.hidden = false;
|
||||
|
||||
const r = node.getBoundingClientRect();
|
||||
const t = el.tip.getBoundingClientRect();
|
||||
// Above the node by default; flipped below when the top of the viewport is
|
||||
// too close, so the card is never clipped.
|
||||
let y = r.top - t.height - 10;
|
||||
if (y < 8) y = r.bottom + 10;
|
||||
let x = r.left + r.width / 2 - t.width / 2;
|
||||
x = Math.max(8, Math.min(x, window.innerWidth - t.width - 8));
|
||||
el.tip.style.left = x + 'px';
|
||||
el.tip.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function hideTip() {
|
||||
el.tip.hidden = true;
|
||||
}
|
||||
|
||||
// ---- lineage ---------------------------------------------------------------
|
||||
|
||||
let selectedId = null;
|
||||
|
||||
function edgePath(from, to) {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
// Bow perpendicular to the chord so cross-system edges read as an arc rather
|
||||
// than a wire drawn straight through the systems between them.
|
||||
const bow = Math.min(len * 0.12, 80);
|
||||
const cx = (from.x + to.x) / 2 + (-dy / len) * bow;
|
||||
const cy = (from.y + to.y) / 2 + (dx / len) * bow;
|
||||
return 'M' + from.x.toFixed(1) + ' ' + from.y.toFixed(1) +
|
||||
'Q' + cx.toFixed(1) + ' ' + cy.toFixed(1) + ',' + to.x.toFixed(1) + ' ' + to.y.toFixed(1);
|
||||
}
|
||||
|
||||
function drawLineage() {
|
||||
if (!selectedId) {
|
||||
el.lineage.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const sel = byId.get(selectedId);
|
||||
const from = posOf.get(selectedId);
|
||||
if (!from) return;
|
||||
const kin = (childrenOf.get(selectedId) || []).slice();
|
||||
if (sel.parent && byId.has(sel.parent)) kin.unshift(sel.parent);
|
||||
|
||||
let out = '';
|
||||
for (const id of kin) {
|
||||
const to = posOf.get(id);
|
||||
if (!to || id === selectedId) continue;
|
||||
out +=
|
||||
'<path class="edge" d="' + edgePath(from, to) + '" />' +
|
||||
'<circle class="edge-end" cx="' + to.x.toFixed(1) + '" cy="' + to.y.toFixed(1) + '" r="2.5" />';
|
||||
}
|
||||
el.lineage.innerHTML = out;
|
||||
}
|
||||
|
||||
// ---- selection panel -------------------------------------------------------
|
||||
|
||||
function tailHtml(a) {
|
||||
const lines = F.fakeTail(a);
|
||||
const padded = new Array(Math.max(0, 8 - lines.length)).fill('').concat(lines.slice(-8));
|
||||
let lastIdx = -1;
|
||||
padded.forEach((l, i) => {
|
||||
if (l.trim()) lastIdx = i;
|
||||
});
|
||||
return padded
|
||||
.map((l, i) => '<div' + (i === lastIdx ? ' class="last"' : '') + '>' + (esc(l) || ' ') + '</div>')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function respondHtml(a) {
|
||||
if (a.state === 'attention') {
|
||||
const opts = a.question.options
|
||||
.map((o, i) => '<button class="opt" type="button"><span class="num">' + (i + 1) + '</span>' + esc(o) + '</button>')
|
||||
.join('');
|
||||
return (
|
||||
'<p class="p-ask">' + esc(a.question.text) + '</p><div class="opts">' + opts + '</div>' +
|
||||
'<div class="reply"><input type="text" placeholder="Or answer in your own words…" aria-label="Reply" />' +
|
||||
'<button class="btn is-primary" type="button">Send</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'permission') {
|
||||
const d = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
return (
|
||||
'<p class="p-cmd">$ ' + esc(d ? d.command : '') + '</p>' +
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Allow once</button>' +
|
||||
'<button class="btn" type="button">Allow for project</button>' +
|
||||
'<button class="btn is-danger" type="button">Deny</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'failed') {
|
||||
return (
|
||||
'<p class="p-fail">' + esc(a.failure) + '</p>' +
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Retry</button>' +
|
||||
'<button class="btn" type="button">Open terminal</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'review') {
|
||||
return (
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Review diff' +
|
||||
(a.pr ? ' #' + a.pr.number : '') + '</button>' +
|
||||
'<button class="btn" type="button">Mark reviewed</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'working') {
|
||||
return (
|
||||
'<div class="reply"><input type="text" placeholder="Queue a message…" aria-label="Queue a message" />' +
|
||||
'<button class="btn is-danger" type="button">Interrupt</button></div>'
|
||||
);
|
||||
}
|
||||
return '<div class="btnrow"><button class="btn" type="button">Open terminal</button></div>';
|
||||
}
|
||||
|
||||
function renderPanel(a) {
|
||||
if (!a) {
|
||||
el.panel.classList.remove('is-open');
|
||||
el.panel.setAttribute('aria-hidden', 'true');
|
||||
return;
|
||||
}
|
||||
el.panel.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')');
|
||||
el.panel.innerHTML =
|
||||
'<button class="panel-close" type="button" aria-label="Close">×</button>' +
|
||||
'<div class="panel-body">' +
|
||||
'<h2 class="p-name">' + esc(a.shortName) + '</h2>' +
|
||||
'<div class="p-state"><i class="dot"></i>' + STATE_LABEL[a.state] +
|
||||
'<span class="live">· ' + liveness(a) + '</span></div>' +
|
||||
'<p class="p-path">' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '</p>' +
|
||||
'<div class="p-facts">' +
|
||||
'<div><div class="eyebrow">Started</div><div class="v">' + clock(a.startedAt) + '</div></div>' +
|
||||
'<div><div class="eyebrow">Elapsed</div><div class="v">' + F.ageLabel(a.startedAgo) + '</div></div>' +
|
||||
'<div><div class="eyebrow">Duration</div><div class="v">' + F.ageLabel(a.durationMs) + '</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="p-tail">' + tailHtml(a) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="p-respond">' + respondHtml(a) + '</div>';
|
||||
el.panel.classList.add('is-open');
|
||||
el.panel.setAttribute('aria-hidden', 'false');
|
||||
el.panel.querySelector('.panel-close').addEventListener('click', () => select(null));
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (selectedId && nodeOf.has(selectedId)) nodeOf.get(selectedId).classList.remove('is-selected');
|
||||
selectedId = id;
|
||||
if (id && nodeOf.has(id)) nodeOf.get(id).classList.add('is-selected');
|
||||
renderPanel(id ? byId.get(id) : null);
|
||||
drawLineage();
|
||||
}
|
||||
|
||||
// ---- search ----------------------------------------------------------------
|
||||
|
||||
function applySearch(raw) {
|
||||
const q = raw.trim().toLowerCase();
|
||||
let hits = 0;
|
||||
for (const sys of systems) {
|
||||
for (const cl of sys.clusters) {
|
||||
let clusterHit = false;
|
||||
for (const n of cl.nodes) {
|
||||
const a = n.a;
|
||||
const hit =
|
||||
!q ||
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.worktree.toLowerCase().includes(q) ||
|
||||
a.displayProject.toLowerCase().includes(q) ||
|
||||
a.agentType.toLowerCase().includes(q) ||
|
||||
a.state.includes(q) ||
|
||||
STATE_LABEL[a.state].toLowerCase().includes(q);
|
||||
// Matches keep their position; misses only lose opacity.
|
||||
nodeOf.get(a.id).classList.toggle('is-dim', Boolean(q) && !hit);
|
||||
if (hit) {
|
||||
clusterHit = true;
|
||||
if (q) hits++;
|
||||
}
|
||||
}
|
||||
nodeOf.get(cl.nodes[0].a.id).parentNode.classList.toggle('is-dim', Boolean(q) && !clusterHit);
|
||||
}
|
||||
}
|
||||
const dead = Boolean(q) && hits === 0;
|
||||
el.empty.hidden = !dead;
|
||||
if (dead) el.empty.textContent = 'No agents match “' + raw.trim() + '”';
|
||||
}
|
||||
|
||||
// ---- wiring ----------------------------------------------------------------
|
||||
|
||||
renderChrome();
|
||||
renderCanvas();
|
||||
|
||||
let panStart = null;
|
||||
let dragged = false;
|
||||
|
||||
// The viewport tools float over the stage, so their clicks must not read as a
|
||||
// pan start or as a click on empty canvas.
|
||||
const onChrome = (e) => Boolean(e.target.closest('.viewtools'));
|
||||
|
||||
el.stage.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0 || onChrome(e)) return;
|
||||
panStart = { x: e.clientX, y: e.clientY, tx: tf.x, ty: tf.y };
|
||||
dragged = false;
|
||||
el.stage.classList.add('is-panning');
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!panStart) return;
|
||||
const dx = e.clientX - panStart.x;
|
||||
const dy = e.clientY - panStart.y;
|
||||
if (!dragged && (Math.abs(dx) > DRAG_THRESHOLD_PX || Math.abs(dy) > DRAG_THRESHOLD_PX)) {
|
||||
dragged = true;
|
||||
// A tooltip would otherwise hang frozen while the content slides under it.
|
||||
hideTip();
|
||||
}
|
||||
if (!dragged) return;
|
||||
tf.x = panStart.tx + dx;
|
||||
tf.y = panStart.ty + dy;
|
||||
applyTransform();
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
panStart = null;
|
||||
el.stage.classList.remove('is-panning');
|
||||
});
|
||||
|
||||
// Native listeners with { passive: false }: the page must not scroll or
|
||||
// page-zoom while the canvas is being manipulated.
|
||||
el.stage.addEventListener(
|
||||
'wheel',
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
// Chromium delivers a trackpad pinch as a ctrlKey wheel with small deltas;
|
||||
// both paths zoom here, pinch at higher sensitivity.
|
||||
const sensitivity = e.ctrlKey ? 0.02 : 0.0015;
|
||||
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * sensitivity));
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
// macOS Chromium also emits WebKit-legacy gesture events for trackpad pinch,
|
||||
// and in some windows that is the only path that fires.
|
||||
let gScale = 1;
|
||||
el.stage.addEventListener('gesturestart', (e) => {
|
||||
e.preventDefault();
|
||||
gScale = 1;
|
||||
});
|
||||
el.stage.addEventListener('gesturechange', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.scale / gScale;
|
||||
gScale = e.scale;
|
||||
if (Number.isFinite(delta) && delta > 0) zoomAt(e.clientX, e.clientY, delta);
|
||||
});
|
||||
el.stage.addEventListener('gestureend', (e) => e.preventDefault());
|
||||
|
||||
el.stage.addEventListener('click', (e) => {
|
||||
// mouseup runs before click, so a finished pan is already flagged here.
|
||||
if (dragged || onChrome(e)) return;
|
||||
const node = e.target.closest('.node');
|
||||
if (!node) {
|
||||
if (selectedId) select(null);
|
||||
return;
|
||||
}
|
||||
select(node.dataset.agent === selectedId ? null : node.dataset.agent);
|
||||
});
|
||||
|
||||
el.stage.addEventListener('dblclick', (e) => {
|
||||
if (e.target.closest('.node')) return;
|
||||
fitAll();
|
||||
});
|
||||
|
||||
el.stage.addEventListener('mouseover', (e) => {
|
||||
if (dragged || panStart) return;
|
||||
const node = e.target.closest('.node');
|
||||
if (node) showTip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.stage.addEventListener('mouseout', (e) => {
|
||||
if (e.target.closest('.node')) hideTip();
|
||||
});
|
||||
el.stage.addEventListener('focusin', (e) => {
|
||||
const node = e.target.closest('.node');
|
||||
if (node) showTip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.stage.addEventListener('focusout', hideTip);
|
||||
el.stage.addEventListener('keydown', (e) => {
|
||||
const node = e.target.closest('.node');
|
||||
if (node && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
select(node.dataset.agent === selectedId ? null : node.dataset.agent);
|
||||
}
|
||||
});
|
||||
|
||||
el.fit.addEventListener('click', () => fitAll());
|
||||
el.zoomreset.addEventListener('click', () => {
|
||||
const r = el.stage.getBoundingClientRect();
|
||||
zoomAt(r.left + r.width / 2, r.top + r.height / 2, 1 / tf.scale);
|
||||
});
|
||||
|
||||
el.search.addEventListener('input', () => applySearch(el.search.value));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key.toLowerCase() === 'k' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
el.search.focus();
|
||||
el.search.select();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (document.activeElement === el.search && el.search.value) {
|
||||
el.search.value = '';
|
||||
applySearch('');
|
||||
} else if (selectedId) {
|
||||
select(null);
|
||||
}
|
||||
hideTip();
|
||||
}
|
||||
});
|
||||
|
||||
// Fit is a viewport operation, not a layout one: resizing re-frames the same
|
||||
// world only while the user has not taken over the viewport themselves.
|
||||
let userMoved = false;
|
||||
el.stage.addEventListener('wheel', () => (userMoved = true), { passive: true });
|
||||
el.stage.addEventListener('mousedown', () => (userMoved = true));
|
||||
window.addEventListener('resize', () => {
|
||||
if (!userMoved) fitAll(SEED_FLOOR);
|
||||
});
|
||||
|
||||
// Pre-seed: fit the whole fleet inside the label band, then open the one
|
||||
// needs-you agent whose parent lives in another repo system — the first paint
|
||||
// carries a cross-system lineage edge and the numbered reply panel.
|
||||
fitAll(SEED_FLOOR);
|
||||
const seed =
|
||||
F.agents.find(
|
||||
(a) =>
|
||||
a.state === 'attention' &&
|
||||
a.parent &&
|
||||
byId.has(a.parent) &&
|
||||
byId.get(a.parent).displayProject !== a.displayProject,
|
||||
) ||
|
||||
F.agents.find((a) => a.state === 'attention' && a.parent && byId.has(a.parent)) ||
|
||||
F.agents.find((a) => a.state === 'attention' && a.pinned) ||
|
||||
F.buckets.needsYou[0];
|
||||
select(seed ? seed.id : null);
|
||||
userMoved = false;
|
||||
})();
|
||||
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1,479 @@
|
||||
/* Fleet · Glass — board structure, rendered as living material.
|
||||
Words exist only where something needs you; everyone else is light.
|
||||
Depth comes from neutral gradients and glow, never from new hues. */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
radial-gradient(1100px 700px at 24% -12%, rgb(255 255 255 / 0.045), transparent 60%),
|
||||
radial-gradient(900px 600px at 88% 110%, rgb(255 255 255 / 0.025), transparent 55%),
|
||||
var(--background);
|
||||
}
|
||||
|
||||
/* ---- chrome ---------------------------------------------------------------- */
|
||||
|
||||
.bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 52px;
|
||||
padding: 0 26px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.tabs a {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.tabs a:hover {
|
||||
color: var(--foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
.tabs .is-active {
|
||||
color: var(--foreground);
|
||||
font-weight: 550;
|
||||
}
|
||||
.search {
|
||||
margin-left: auto;
|
||||
width: 200px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.search::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.search:focus {
|
||||
outline: none;
|
||||
background: rgb(255 255 255 / 0.06);
|
||||
border-color: var(--ring);
|
||||
}
|
||||
.accounting {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- stat band: typographic drama ----------------------------------------- */
|
||||
|
||||
.stats {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 44px;
|
||||
padding: 10px 28px 20px;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.stat .n {
|
||||
font-size: 34px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
.stat .l {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.stat.is-attention .n {
|
||||
color: var(--state-attention);
|
||||
text-shadow: 0 0 22px color-mix(in srgb, var(--state-attention) 45%, transparent);
|
||||
}
|
||||
.stat.is-working .n {
|
||||
color: color-mix(in srgb, var(--foreground) 88%, transparent);
|
||||
}
|
||||
.stat.is-review .n {
|
||||
color: color-mix(in srgb, var(--state-done) 80%, var(--foreground));
|
||||
}
|
||||
.stat.is-done .n {
|
||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
|
||||
/* ---- field ----------------------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 26px 46px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--accent) transparent;
|
||||
transition: padding-right 190ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
body.panel-open .field {
|
||||
padding-right: 402px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 26px 4px 14px;
|
||||
}
|
||||
.section-head:first-child {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.section-head .meta {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ---- the glass card -------------------------------------------------------- */
|
||||
|
||||
.wt {
|
||||
position: relative;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.055), rgb(255 255 255 / 0.02) 55%);
|
||||
border: 1px solid rgb(255 255 255 / 0.075);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px 11px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.05),
|
||||
0 10px 28px -14px rgb(0 0 0 / 0.7);
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, opacity 0.14s ease;
|
||||
}
|
||||
.wt:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(255 255 255 / 0.13);
|
||||
}
|
||||
.wt.is-dim {
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
/* Needs-you cards bloom. */
|
||||
.wt[data-worst='attention'],
|
||||
.wt[data-worst='permission'] {
|
||||
border-color: color-mix(in srgb, var(--state-attention) 50%, rgb(255 255 255 / 0.08));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.05),
|
||||
0 0 30px -8px color-mix(in srgb, var(--state-attention) 55%, transparent),
|
||||
0 10px 28px -14px rgb(0 0 0 / 0.7);
|
||||
}
|
||||
.wt[data-worst='failed'] {
|
||||
border-color: color-mix(in srgb, var(--state-blocked) 55%, rgb(255 255 255 / 0.08));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.05),
|
||||
0 0 30px -8px color-mix(in srgb, var(--state-blocked) 50%, transparent),
|
||||
0 10px 28px -14px rgb(0 0 0 / 0.7);
|
||||
}
|
||||
|
||||
.wt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.wt-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.005em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wt-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- orbs: agents as light ------------------------------------------------- */
|
||||
|
||||
.orbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-top: 10px;
|
||||
min-height: 12px;
|
||||
}
|
||||
.orb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: transform 0.14s ease;
|
||||
}
|
||||
.orb:hover {
|
||||
transform: scale(1.45);
|
||||
}
|
||||
.orb.is-selected {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.orb[data-state='done'] {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: rgb(255 255 255 / 0.14);
|
||||
}
|
||||
.orb[data-state='review'] {
|
||||
background: color-mix(in srgb, var(--state-done) 72%, transparent);
|
||||
box-shadow: 0 0 10px -1px color-mix(in srgb, var(--state-done) 55%, transparent);
|
||||
}
|
||||
.orb[data-state='working'] {
|
||||
background: color-mix(in srgb, var(--state-working) 62%, transparent);
|
||||
box-shadow: 0 0 10px -1px color-mix(in srgb, var(--state-working) 50%, transparent);
|
||||
animation: orb-breathe 3.2s ease-in-out infinite;
|
||||
}
|
||||
.orb[data-state='attention'],
|
||||
.orb[data-state='permission'] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--state-attention);
|
||||
box-shadow: 0 0 14px 0 color-mix(in srgb, var(--state-attention) 70%, transparent);
|
||||
animation: orb-alert var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
.orb[data-state='failed'] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--state-blocked);
|
||||
box-shadow: 0 0 14px 0 color-mix(in srgb, var(--state-blocked) 65%, transparent);
|
||||
animation: orb-alert var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
@keyframes orb-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||
50% { transform: scale(1.12); opacity: 1; }
|
||||
}
|
||||
@keyframes orb-alert {
|
||||
0%, 44% { opacity: 1; }
|
||||
50%, 94% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
/* Words only where something needs you. */
|
||||
.ask {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: color-mix(in srgb, var(--foreground) 92%, transparent);
|
||||
}
|
||||
.ask .orb {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ask span {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* ---- tooltip ---------------------------------------------------------------- */
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
max-width: 300px;
|
||||
background: var(--popover);
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-floating);
|
||||
padding: 9px 11px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tooltip .t-name {
|
||||
color: var(--foreground);
|
||||
font-weight: 550;
|
||||
}
|
||||
.tooltip .t-meta {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- panel ------------------------------------------------------------------ */
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 52px;
|
||||
right: 14px;
|
||||
bottom: 34px;
|
||||
width: 376px;
|
||||
background: linear-gradient(180deg, rgb(255 255 255 / 0.06), rgb(255 255 255 / 0.02) 40%), var(--card);
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 16px;
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06), var(--shadow-floating);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
z-index: 30;
|
||||
}
|
||||
.panel h1 {
|
||||
margin: 0 34px 8px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.panel .close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel .state-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.panel .path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.85;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.panel .tail {
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 12px;
|
||||
background: rgb(0 0 0 / 0.35);
|
||||
border: 1px solid rgb(255 255 255 / 0.06);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel .tail .now {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.panel .question {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.panel .opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.panel .opts button,
|
||||
.panel .actions button {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
border-radius: 10px;
|
||||
padding: 9px 13px;
|
||||
text-align: left;
|
||||
color: var(--foreground);
|
||||
background: rgb(255 255 255 / 0.055);
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease;
|
||||
}
|
||||
.panel .opts button:hover,
|
||||
.panel .actions button:hover {
|
||||
background: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
.panel .opts kbd {
|
||||
margin-right: 9px;
|
||||
}
|
||||
.panel .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.panel .actions .primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
.panel .reply {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.panel .reply input {
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
background: rgb(0 0 0 / 0.3);
|
||||
border: 1px solid rgb(255 255 255 / 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.panel .reply input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
/* ---- whisper ---------------------------------------------------------------- */
|
||||
|
||||
.whisper {
|
||||
flex: 0 0 auto;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding: 0 18px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.whisper span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.whisper i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fleet — Glass</title>
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-glass.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<span class="wordmark">Fleet</span>
|
||||
<nav class="tabs">
|
||||
<a href="./fleet-board.html">Board</a>
|
||||
<a href="./fleet-tiles.html">Tiles</a>
|
||||
<a href="./fleet-triage.html">Triage</a>
|
||||
<span class="is-active">Glass</span>
|
||||
</nav>
|
||||
<input class="search" id="search" type="text" />
|
||||
<span class="accounting" id="accounting"></span>
|
||||
</header>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<main class="field" id="field"></main>
|
||||
|
||||
<aside class="panel" id="panel" hidden></aside>
|
||||
<div class="tooltip" id="tooltip" hidden></div>
|
||||
|
||||
<footer class="whisper" id="whisper"></footer>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-glass.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,228 @@
|
||||
/* Fleet · Glass — worktree glass cards with agents as orbs, from window.FLEET. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const F = window.FLEET;
|
||||
const MAC = navigator.userAgent.includes('Mac');
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
const STATE_LABEL = {
|
||||
attention: 'Needs an answer', permission: 'Awaiting permission', failed: 'Failed',
|
||||
working: 'Working', review: 'Ready to review', done: 'Done',
|
||||
};
|
||||
|
||||
// ---- chrome ---------------------------------------------------------------
|
||||
document.getElementById('accounting').textContent =
|
||||
F.counts.total + ' / ' + F.counts.fleetTotal + ' agents · ' +
|
||||
F.byWorktree.length + ' / ' + F.counts.fleetWorktrees + ' worktrees';
|
||||
document.getElementById('search').placeholder = 'Filter… ' + (MAC ? '⌘' : 'Ctrl+') + 'K';
|
||||
|
||||
const stats = document.getElementById('stats');
|
||||
[
|
||||
['is-attention', F.counts.needsYou, 'need you'],
|
||||
['is-working', F.counts.working, 'working'],
|
||||
['is-review', F.counts.review, 'to review'],
|
||||
['is-done', F.counts.done, 'done'],
|
||||
].forEach(([cls, n, label]) => {
|
||||
const s = el('div', 'stat ' + cls);
|
||||
s.append(el('span', 'n', String(n)), el('span', 'l', label));
|
||||
stats.append(s);
|
||||
});
|
||||
|
||||
// ---- field ----------------------------------------------------------------
|
||||
const field = document.getElementById('field');
|
||||
const orbEls = new Map();
|
||||
let selected = null;
|
||||
|
||||
function tailBlock(a, count) {
|
||||
const pre = el('pre', 'tail');
|
||||
const lines = F.fakeTail(a).slice(-count);
|
||||
lines.forEach((ln, i) => pre.append(el('span', i === lines.length - 1 ? 'now' : null, ln + '\n')));
|
||||
return pre;
|
||||
}
|
||||
|
||||
F.projects.forEach((p) => {
|
||||
const head = el('div', 'section-head');
|
||||
head.append(
|
||||
el('h2', null, p.name),
|
||||
el('span', 'meta', p.worktrees.length + ' worktrees · ' + p.agents.length + ' agents'),
|
||||
);
|
||||
field.append(head);
|
||||
|
||||
const grid = el('div', 'grid');
|
||||
const wts = p.worktrees.slice().sort((a, b) => {
|
||||
const na = F.severityRank(a.worstState) <= 2 ? 0 : 1;
|
||||
const nb = F.severityRank(b.worstState) <= 2 ? 0 : 1;
|
||||
return na - nb || a.worktree.localeCompare(b.worktree);
|
||||
});
|
||||
|
||||
wts.forEach((g) => {
|
||||
const card = el('article', 'wt');
|
||||
card.dataset.worst = g.worstState;
|
||||
card.dataset.search = (g.worktree + ' ' + g.agents.map((a) => a.name).join(' ')).toLowerCase();
|
||||
|
||||
const head = el('div', 'wt-head');
|
||||
head.append(el('span', 'wt-name', g.worktree), el('span', 'wt-count', String(g.agents.length)));
|
||||
card.append(head);
|
||||
|
||||
const orbs = el('div', 'orbs');
|
||||
const loud = [];
|
||||
g.agents.forEach((a) => {
|
||||
const o = el('i', 'orb');
|
||||
o.dataset.state = a.state;
|
||||
o.addEventListener('click', () => select(a, o));
|
||||
o.addEventListener('mousemove', (e) => showTip(e, a));
|
||||
o.addEventListener('mouseleave', hideTip);
|
||||
orbs.append(o);
|
||||
orbEls.set(a.id, o);
|
||||
if (a.bucket === 'needsYou') loud.push(a);
|
||||
});
|
||||
card.append(orbs);
|
||||
|
||||
// Words only where something needs you.
|
||||
loud.forEach((a) => {
|
||||
const ask = el('div', 'ask');
|
||||
const o = el('i', 'orb');
|
||||
o.dataset.state = a.state;
|
||||
o.style.animation = 'none';
|
||||
const text =
|
||||
a.state === 'attention' ? a.question.text :
|
||||
a.state === 'permission' ? 'Allow ' + (F.decisions.find((d) => d.key === a.decisionKey) || {}).command + '?' :
|
||||
a.failure;
|
||||
ask.append(o, el('span', null, text));
|
||||
ask.addEventListener('click', () => select(a, orbEls.get(a.id)));
|
||||
ask.style.cursor = 'pointer';
|
||||
card.append(ask);
|
||||
});
|
||||
|
||||
grid.append(card);
|
||||
});
|
||||
field.append(grid);
|
||||
});
|
||||
|
||||
// ---- tooltip --------------------------------------------------------------
|
||||
const tip = document.getElementById('tooltip');
|
||||
function showTip(e, a) {
|
||||
tip.hidden = false;
|
||||
tip.replaceChildren(
|
||||
el('div', 't-name', a.shortName),
|
||||
el('div', 't-meta', STATE_LABEL[a.state] + ' · ' + a.agentType + ' · ' +
|
||||
F.ageLabel(a.state === 'done' || a.state === 'review' ? a.durationMs : a.startedAgo) +
|
||||
(a.detail && a.state === 'working' ? ' · ' + a.detail : '')),
|
||||
);
|
||||
const pad = 14;
|
||||
const w = tip.offsetWidth;
|
||||
const h = tip.offsetHeight;
|
||||
let x = e.clientX + pad;
|
||||
let y = e.clientY - h - 10;
|
||||
if (x + w > innerWidth - 8) x = e.clientX - w - pad;
|
||||
if (y < 60) y = e.clientY + pad;
|
||||
tip.style.left = x + 'px';
|
||||
tip.style.top = y + 'px';
|
||||
}
|
||||
function hideTip() {
|
||||
tip.hidden = true;
|
||||
}
|
||||
|
||||
// ---- panel ----------------------------------------------------------------
|
||||
const panel = document.getElementById('panel');
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
document.body.classList.remove('panel-open');
|
||||
if (selected) orbEls.get(selected.id)?.classList.remove('is-selected');
|
||||
selected = null;
|
||||
}
|
||||
function select(a, orb) {
|
||||
if (selected) orbEls.get(selected.id)?.classList.remove('is-selected');
|
||||
selected = a;
|
||||
orb?.classList.add('is-selected');
|
||||
panel.hidden = false;
|
||||
document.body.classList.add('panel-open');
|
||||
panel.replaceChildren();
|
||||
|
||||
const close = el('button', 'close', '✕');
|
||||
close.addEventListener('click', closePanel);
|
||||
panel.append(close, el('h1', null, a.shortName));
|
||||
|
||||
const sl = el('div', 'state-line');
|
||||
const d = el('i', 'orb');
|
||||
d.dataset.state = a.state;
|
||||
d.style.animation = 'none';
|
||||
sl.append(d, el('span', null, STATE_LABEL[a.state] + ' · ' + (a.alive ? 'process live' : 'reply restarts it')));
|
||||
panel.append(sl, el('p', 'path', a.displayProject + ' / ' + a.worktree));
|
||||
panel.append(tailBlock(a, 8));
|
||||
|
||||
if (a.state === 'attention') {
|
||||
panel.append(el('p', 'question', a.question.text));
|
||||
const opts = el('div', 'opts');
|
||||
a.question.options.forEach((opt, i) => {
|
||||
const b = el('button');
|
||||
b.append(el('kbd', null, String(i + 1)), document.createTextNode(opt));
|
||||
opts.append(b);
|
||||
});
|
||||
panel.append(opts);
|
||||
} else if (a.state === 'permission') {
|
||||
const dec = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
panel.append(el('p', 'question', 'Allow ' + dec.command + '? ' + dec.agents.length + ' agents are waiting on this one decision.'));
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Allow — resumes ' + dec.agents.length), el('button', null, 'Deny'));
|
||||
panel.append(actions);
|
||||
} else if (a.state === 'failed') {
|
||||
panel.append(el('p', 'question', a.failure));
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Retry'), el('button', null, 'Open terminal'));
|
||||
panel.append(actions);
|
||||
} else if (a.state === 'review') {
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Review diff'), el('button', null, 'Mark reviewed'));
|
||||
panel.append(actions);
|
||||
}
|
||||
const reply = el('div', 'reply');
|
||||
reply.append(Object.assign(el('input'), {
|
||||
placeholder: a.state === 'working' ? 'Queue a message…' : 'Reply…',
|
||||
}));
|
||||
panel.append(reply);
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closePanel();
|
||||
});
|
||||
|
||||
// ---- search ---------------------------------------------------------------
|
||||
const search = document.getElementById('search');
|
||||
search.addEventListener('input', () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
document.querySelectorAll('.wt').forEach((card) => {
|
||||
card.classList.toggle('is-dim', !!q && !card.dataset.search.includes(q));
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((MAC ? e.metaKey : e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
search.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- whisper --------------------------------------------------------------
|
||||
const whisper = document.getElementById('whisper');
|
||||
[
|
||||
['var(--state-attention)', 'needs you'],
|
||||
['color-mix(in srgb, var(--state-working) 62%, transparent)', 'working'],
|
||||
['color-mix(in srgb, var(--state-done) 72%, transparent)', 'review'],
|
||||
['rgb(255 255 255 / 0.14)', 'done'],
|
||||
].forEach(([color, label]) => {
|
||||
const s = el('span');
|
||||
const i = el('i');
|
||||
i.style.background = color;
|
||||
s.append(i, el('span', null, label));
|
||||
whisper.append(s);
|
||||
});
|
||||
whisper.append(el('span', null, 'captured ' + new Date(F.now).toISOString().slice(11, 16) + ' UTC'));
|
||||
|
||||
// ---- pre-seed -------------------------------------------------------------
|
||||
const seed = F.projects.flatMap((p) => p.agents).find((a) => a.state === 'attention');
|
||||
if (seed) select(seed, orbEls.get(seed.id));
|
||||
})();
|
||||
|
After Width: | Height: | Size: 263 KiB |
@@ -0,0 +1,688 @@
|
||||
/* Fleet Hexmap — one hexagon per agent, zero text in the field.
|
||||
Color/shape/motion carry state; words live only in the tooltip and the panel. */
|
||||
|
||||
:root {
|
||||
--hex-w: 52px;
|
||||
--hex-h: calc(var(--hex-w) * 1.1547); /* pointy-top: H = W · 2/√3 */
|
||||
--hex-clip: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
--dim: 0.18;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- top bar --------------------------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tabs a {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tabs a.is-active {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chip .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chip .n {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip .lbl {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip.is-loud .dot {
|
||||
opacity: 1;
|
||||
box-shadow: var(--glow-attention);
|
||||
}
|
||||
|
||||
.chip.is-loud .n {
|
||||
color: var(--state-attention);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chips .sep {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topright {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 190px;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.search::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accounting {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- the field ------------------------------------------------------------- */
|
||||
|
||||
.field-wrap {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section + .section {
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin: 0 0 11px;
|
||||
}
|
||||
|
||||
.plane {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lineage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ---- hexagon --------------------------------------------------------------- */
|
||||
|
||||
.hex {
|
||||
position: absolute;
|
||||
width: var(--hex-w);
|
||||
height: var(--hex-h);
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.hex.is-dim {
|
||||
opacity: var(--dim);
|
||||
}
|
||||
|
||||
/* No display here: abspos blockifies on its own, and declaring it would outrank
|
||||
.hex-mark's grid and drop the glyph out of centre. */
|
||||
.hex > i {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Whole paint area is border, so the shared blink/pulse keyframes (which animate
|
||||
border-color) drive the edge color on a shape that cannot carry a real border. */
|
||||
.hex-edge {
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
border-style: solid;
|
||||
border-width: calc(var(--hex-h) / 2) calc(var(--hex-w) / 2);
|
||||
border-color: var(--border);
|
||||
clip-path: var(--hex-clip);
|
||||
}
|
||||
|
||||
.hex-face {
|
||||
inset: 1px;
|
||||
background: var(--card);
|
||||
clip-path: var(--hex-clip);
|
||||
}
|
||||
|
||||
/* Unbordered circle behind the face — only the keyframes' box-shadow shows,
|
||||
giving the amber cells a halo that breathes in step with the edge. */
|
||||
.hex-glow {
|
||||
inset: 4px;
|
||||
border: 0 solid transparent;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.hex-mark {
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ring {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-left-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin calc(var(--pulse-slow) * 1.25) linear infinite;
|
||||
}
|
||||
|
||||
.crescent {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset -2.5px 0 0 0 currentColor;
|
||||
}
|
||||
|
||||
/* Liveness is its own channel: an exited process draws hollow. */
|
||||
.hex.is-dead .glyph {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 0.7px var(--hue);
|
||||
}
|
||||
|
||||
.hex.is-dead .ring {
|
||||
animation: none;
|
||||
border-color: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* -- per state -- */
|
||||
|
||||
.hex[data-state='done'] {
|
||||
--hue: var(--state-idle);
|
||||
}
|
||||
|
||||
.hex[data-state='review'] {
|
||||
--hue: var(--state-done);
|
||||
}
|
||||
|
||||
.hex[data-state='review'] .hex-face {
|
||||
background: color-mix(in srgb, var(--state-done) 10%, var(--card));
|
||||
}
|
||||
|
||||
.hex[data-state='review'] .hex-edge {
|
||||
animation: pulse-review var(--pulse-slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hex[data-state='review'] .hex-mark {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.hex[data-state='review'].is-dead .hex-mark {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hex[data-state='working'] {
|
||||
--hue: var(--state-working);
|
||||
}
|
||||
|
||||
.hex[data-state='working'] .hex-face {
|
||||
background: color-mix(in srgb, var(--state-working) 8%, var(--card));
|
||||
}
|
||||
|
||||
.hex[data-state='working'] .hex-edge {
|
||||
border-color: color-mix(in srgb, var(--state-working) 22%, var(--border));
|
||||
}
|
||||
|
||||
.hex[data-state='working'] .hex-mark {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hex[data-state='attention'],
|
||||
.hex[data-state='permission'] {
|
||||
--hue: var(--state-attention);
|
||||
}
|
||||
|
||||
.hex[data-state='attention'] .hex-face,
|
||||
.hex[data-state='permission'] .hex-face {
|
||||
background: color-mix(in srgb, var(--state-attention) 18%, var(--card));
|
||||
}
|
||||
|
||||
.hex[data-state='attention'] .hex-edge,
|
||||
.hex[data-state='permission'] .hex-edge,
|
||||
.hex[data-state='attention'] .hex-glow,
|
||||
.hex[data-state='permission'] .hex-glow {
|
||||
border-color: var(--state-attention);
|
||||
animation: blink-border-attention var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.hex[data-state='failed'] {
|
||||
--hue: var(--state-blocked);
|
||||
}
|
||||
|
||||
.hex[data-state='failed'] .hex-face {
|
||||
background: color-mix(in srgb, var(--state-blocked) 18%, var(--card));
|
||||
}
|
||||
|
||||
.hex[data-state='failed'] .hex-edge,
|
||||
.hex[data-state='failed'] .hex-glow {
|
||||
border-color: var(--state-blocked);
|
||||
animation: blink-border-blocked var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
/* -- selection / hover -- */
|
||||
|
||||
.hex::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
clip-path: var(--hex-clip);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hex.is-selected {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.hex.is-selected::before {
|
||||
background: var(--ring);
|
||||
}
|
||||
|
||||
.hex:hover::before,
|
||||
.hex:focus-visible::before {
|
||||
background: color-mix(in srgb, var(--ring) 45%, transparent);
|
||||
}
|
||||
|
||||
.hex.is-selected:hover::before {
|
||||
background: var(--ring);
|
||||
}
|
||||
|
||||
/* ---- footer whisper -------------------------------------------------------- */
|
||||
|
||||
.whisper {
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 22px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.whisper .key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.whisper .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.whisper .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ---- tooltip: the only text the field ever shows --------------------------- */
|
||||
|
||||
.tip {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
max-width: 280px;
|
||||
padding: 9px 11px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-floating);
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tip .t-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.tip .t-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.tip .t-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta {
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta .mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- selection panel ------------------------------------------------------- */
|
||||
|
||||
/* Spans the field, not the window: a truly full-height sheet would bury the
|
||||
accounting whisper and the legend, and the panel opens pre-seeded. */
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 46px;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
z-index: 50;
|
||||
width: 370px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-floating);
|
||||
transform: translateX(101%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 190ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
visibility 190ms;
|
||||
}
|
||||
|
||||
.panel.is-open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 22px 8px;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-name {
|
||||
margin: 0 30px 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.p-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.p-path {
|
||||
margin: 8px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.p-facts {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
margin: 16px 0 14px;
|
||||
}
|
||||
|
||||
.p-facts .v {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.p-tail {
|
||||
padding: 9px 11px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-tail > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.p-tail .last {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-respond {
|
||||
padding: 14px 22px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.p-ask {
|
||||
margin: 0 0 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.p-cmd {
|
||||
margin: 0 0 11px;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-fail {
|
||||
margin: 0 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
|
||||
.opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
background: var(--secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt:hover {
|
||||
border-color: var(--input);
|
||||
}
|
||||
|
||||
.opt .num {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.reply input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reply input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply input:focus {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.btnrow {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.btn.is-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.btn.is-primary:hover {
|
||||
background: var(--foreground);
|
||||
}
|
||||
|
||||
.btn.is-danger:hover {
|
||||
border-color: color-mix(in srgb, var(--state-blocked) 55%, var(--border));
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Fleet — Hexmap</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-hexmap.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<span class="brand">Fleet</span>
|
||||
<nav class="tabs" aria-label="Fleet views">
|
||||
<a href="./fleet-map.html">Map</a>
|
||||
<a href="./fleet-strips.html">Strips</a>
|
||||
<a href="./mission-control.html">Mission Control</a>
|
||||
<a class="is-active" href="./fleet-hexmap.html" aria-current="page">Hexmap</a>
|
||||
</nav>
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="topright">
|
||||
<input
|
||||
id="search"
|
||||
class="search"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filter the fleet"
|
||||
/>
|
||||
<span class="accounting" id="accounting"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="field-wrap">
|
||||
<div class="field" id="field">
|
||||
<svg class="lineage" id="lineage" aria-hidden="true"></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="whisper" id="whisper"></footer>
|
||||
</main>
|
||||
|
||||
<aside class="panel" id="panel" aria-label="Agent detail" aria-hidden="true"></aside>
|
||||
<div class="tip" id="tip" role="tooltip" hidden></div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-hexmap.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,386 @@
|
||||
/* Fleet Hexmap — a Datadog-host-map-style honeycomb, one cell per agent.
|
||||
The field itself is wordless: hue, glyph and motion carry state. Every label
|
||||
lives in the hover tooltip or the selection panel. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const F = window.FLEET;
|
||||
if (!F) throw new Error('fleet-state-synthesis.js must be loaded first');
|
||||
|
||||
const IS_MAC = navigator.userAgent.includes('Mac');
|
||||
|
||||
// Pointy-top honeycomb: 18 columns lands orca on 4 near-full rows (18·3 + 15),
|
||||
// so every strip keeps a clean silhouette instead of a stub last row.
|
||||
const COLS = 18;
|
||||
const HEX_W = 52;
|
||||
const GAP = 4;
|
||||
const HEX_H = (HEX_W * 2) / Math.sqrt(3);
|
||||
const PITCH_X = HEX_W + GAP;
|
||||
const PITCH_Y = HEX_H * 0.75 + GAP;
|
||||
const FIELD_W = COLS * PITCH_X - GAP + PITCH_X / 2;
|
||||
|
||||
const STATE_LABEL = {
|
||||
attention: 'Waiting on your answer',
|
||||
permission: 'Waiting on permission',
|
||||
failed: 'Failed',
|
||||
working: 'Working',
|
||||
review: 'Ready to review',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
const el = {
|
||||
field: document.getElementById('field'),
|
||||
lineage: document.getElementById('lineage'),
|
||||
chips: document.getElementById('chips'),
|
||||
accounting: document.getElementById('accounting'),
|
||||
whisper: document.getElementById('whisper'),
|
||||
search: document.getElementById('search'),
|
||||
panel: document.getElementById('panel'),
|
||||
tip: document.getElementById('tip'),
|
||||
};
|
||||
|
||||
const byId = new Map(F.agents.map((a) => [a.id, a]));
|
||||
const childrenOf = new Map();
|
||||
for (const a of F.agents) {
|
||||
if (a.parent && byId.has(a.parent)) {
|
||||
if (!childrenOf.has(a.parent)) childrenOf.set(a.parent, []);
|
||||
childrenOf.get(a.parent).push(a.id);
|
||||
}
|
||||
}
|
||||
const nodeOf = new Map();
|
||||
let selectedId = null;
|
||||
|
||||
const esc = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c],
|
||||
);
|
||||
const clock = (ms) =>
|
||||
new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const liveness = (a) =>
|
||||
a.loopSleeping ? 'loop sleeping' : a.alive ? 'process live' : 'process exited';
|
||||
|
||||
// ---- chrome ----------------------------------------------------------------
|
||||
|
||||
function renderChrome() {
|
||||
const c = F.counts;
|
||||
const chips = [
|
||||
{ n: c.needsYou, lbl: 'need you', hue: 'var(--state-attention)', loud: true },
|
||||
{ n: c.working, lbl: 'working', hue: 'var(--state-working)' },
|
||||
{ n: c.review, lbl: 'to review', hue: 'var(--state-done)' },
|
||||
{ n: c.done, lbl: 'done', hue: 'var(--state-idle)' },
|
||||
];
|
||||
el.chips.innerHTML = chips
|
||||
.map(
|
||||
(x) =>
|
||||
'<span class="chip' +
|
||||
(x.loud ? ' is-loud' : '') +
|
||||
'" style="--hue:' +
|
||||
x.hue +
|
||||
'"><i class="dot"></i><span class="n">' +
|
||||
x.n +
|
||||
'</span><span class="lbl">' +
|
||||
x.lbl +
|
||||
'</span></span>',
|
||||
)
|
||||
.join('<span class="sep">·</span>');
|
||||
|
||||
el.search.placeholder = 'Filter… ' + (IS_MAC ? '⌘K' : 'Ctrl+K');
|
||||
el.accounting.textContent =
|
||||
c.total + ' / ' + c.fleetTotal + ' agents · ' + F.byWorktree.length + ' / ' + c.fleetWorktrees + ' worktrees';
|
||||
|
||||
const keys = [
|
||||
['var(--state-attention)', 'amber = needs you'],
|
||||
['var(--state-working)', 'gold = working'],
|
||||
['var(--state-done)', 'green = review'],
|
||||
['var(--state-idle)', 'dark = done'],
|
||||
];
|
||||
el.whisper.innerHTML =
|
||||
keys
|
||||
.map((k) => '<span class="key"><i class="dot" style="--hue:' + k[0] + '"></i>' + k[1] + '</span>')
|
||||
.join('<span class="sep">·</span>') + '<span class="sep">·</span><span>still = settled</span>';
|
||||
}
|
||||
|
||||
// ---- field -----------------------------------------------------------------
|
||||
|
||||
function markup(a) {
|
||||
if (a.state === 'done') return '';
|
||||
if (a.state === 'working')
|
||||
return a.loopSleeping ? '<i class="crescent"></i>' : '<i class="ring"></i>';
|
||||
return '<span class="glyph">' + F.stateGlyph(a.state) + '</span>';
|
||||
}
|
||||
|
||||
function renderField() {
|
||||
el.field.style.width = FIELD_W + 'px';
|
||||
for (const p of F.projects) {
|
||||
const rows = Math.ceil(p.agents.length / COLS);
|
||||
const section = document.createElement('section');
|
||||
section.className = 'section';
|
||||
const label = document.createElement('p');
|
||||
label.className = 'eyebrow section-label';
|
||||
label.textContent = p.name.toUpperCase() + ' · ' + p.agents.length;
|
||||
const plane = document.createElement('div');
|
||||
plane.className = 'plane';
|
||||
plane.style.height = (rows - 1) * PITCH_Y + HEX_H + 'px';
|
||||
|
||||
p.agents.forEach((a, i) => {
|
||||
const r = Math.floor(i / COLS);
|
||||
const c = i % COLS;
|
||||
const hex = document.createElement('div');
|
||||
hex.className = 'hex' + (a.alive ? '' : ' is-dead');
|
||||
hex.dataset.agent = a.id;
|
||||
hex.dataset.state = a.state;
|
||||
hex.style.left = c * PITCH_X + (r % 2) * (PITCH_X / 2) + 'px';
|
||||
hex.style.top = r * PITCH_Y + 'px';
|
||||
hex.tabIndex = 0;
|
||||
hex.setAttribute('role', 'button');
|
||||
hex.setAttribute(
|
||||
'aria-label',
|
||||
a.shortName + ' — ' + STATE_LABEL[a.state] + ', ' + p.name + '/' + a.worktree,
|
||||
);
|
||||
hex.innerHTML =
|
||||
'<i class="hex-glow"></i><i class="hex-edge"></i><i class="hex-face"></i>' +
|
||||
'<i class="hex-mark">' + markup(a) + '</i>';
|
||||
plane.appendChild(hex);
|
||||
nodeOf.set(a.id, hex);
|
||||
});
|
||||
|
||||
section.append(label, plane);
|
||||
el.field.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tooltip ---------------------------------------------------------------
|
||||
|
||||
function showTip(node, a) {
|
||||
el.tip.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')');
|
||||
el.tip.innerHTML =
|
||||
'<div class="t-name">' + esc(a.shortName) + '</div>' +
|
||||
'<div class="t-state">' + STATE_LABEL[a.state] +
|
||||
'<span class="live">· ' + liveness(a) + '</span></div>' +
|
||||
'<div class="t-meta"><span class="mono">' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '</span><br />' +
|
||||
'started ' + F.ageLabel(a.startedAgo) + ' ago · ran ' + F.ageLabel(a.durationMs) +
|
||||
'<br />' + esc(a.detail || '—') + '</div>';
|
||||
el.tip.hidden = false;
|
||||
|
||||
const r = node.getBoundingClientRect();
|
||||
const t = el.tip.getBoundingClientRect();
|
||||
let x = r.left + r.width / 2 - t.width / 2;
|
||||
let y = r.bottom + 8;
|
||||
if (y + t.height > window.innerHeight - 8) y = r.top - t.height - 8;
|
||||
x = Math.max(8, Math.min(x, window.innerWidth - t.width - 8));
|
||||
el.tip.style.left = x + 'px';
|
||||
el.tip.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function hideTip() {
|
||||
el.tip.hidden = true;
|
||||
}
|
||||
|
||||
// ---- lineage ---------------------------------------------------------------
|
||||
|
||||
function centerOf(id) {
|
||||
const node = nodeOf.get(id);
|
||||
if (!node) return null;
|
||||
const base = el.field.getBoundingClientRect();
|
||||
const r = node.getBoundingClientRect();
|
||||
return { x: r.left - base.left + r.width / 2, y: r.top - base.top + r.height / 2 };
|
||||
}
|
||||
|
||||
function drawLineage() {
|
||||
const base = el.field.getBoundingClientRect();
|
||||
el.lineage.setAttribute('viewBox', '0 0 ' + base.width + ' ' + base.height);
|
||||
if (!selectedId) {
|
||||
el.lineage.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const sel = byId.get(selectedId);
|
||||
const from = centerOf(selectedId);
|
||||
if (!from) return;
|
||||
const kin = (childrenOf.get(selectedId) || []).slice();
|
||||
if (sel.parent && byId.has(sel.parent)) kin.unshift(sel.parent);
|
||||
|
||||
let svg = '';
|
||||
for (const id of kin) {
|
||||
const to = centerOf(id);
|
||||
if (!to) continue;
|
||||
const bend = (to.y - from.y) * 0.45;
|
||||
svg +=
|
||||
'<path d="M' + from.x + ' ' + from.y + 'C' + from.x + ' ' + (from.y + bend) +
|
||||
',' + to.x + ' ' + (to.y - bend) + ',' + to.x + ' ' + to.y +
|
||||
'" fill="none" stroke="var(--muted-foreground)" stroke-width="1" opacity="0.5" />' +
|
||||
'<circle cx="' + to.x + '" cy="' + to.y + '" r="2.5" fill="var(--muted-foreground)" opacity="0.7" />';
|
||||
}
|
||||
el.lineage.innerHTML = svg;
|
||||
}
|
||||
|
||||
// ---- selection panel -------------------------------------------------------
|
||||
|
||||
function tailHtml(a) {
|
||||
const lines = F.fakeTail(a);
|
||||
const padded = new Array(Math.max(0, 8 - lines.length)).fill('').concat(lines.slice(-8));
|
||||
let lastIdx = -1;
|
||||
padded.forEach((l, i) => {
|
||||
if (l.trim()) lastIdx = i;
|
||||
});
|
||||
return padded
|
||||
.map((l, i) => '<div' + (i === lastIdx ? ' class="last"' : '') + '>' + (esc(l) || ' ') + '</div>')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function respondHtml(a) {
|
||||
if (a.state === 'attention') {
|
||||
const opts = a.question.options
|
||||
.map((o, i) => '<button class="opt" type="button"><span class="num">' + (i + 1) + '</span>' + esc(o) + '</button>')
|
||||
.join('');
|
||||
return (
|
||||
'<p class="p-ask">' + esc(a.question.text) + '</p><div class="opts">' + opts + '</div>' +
|
||||
'<div class="reply"><input type="text" placeholder="Or answer in your own words…" aria-label="Reply" />' +
|
||||
'<button class="btn is-primary" type="button">Send</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'permission') {
|
||||
const d = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
return (
|
||||
'<p class="p-cmd">$ ' + esc(d ? d.command : '') + '</p>' +
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Allow once</button>' +
|
||||
'<button class="btn" type="button">Allow for project</button>' +
|
||||
'<button class="btn is-danger" type="button">Deny</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'failed') {
|
||||
return (
|
||||
'<p class="p-fail">' + esc(a.failure) + '</p>' +
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Retry</button>' +
|
||||
'<button class="btn" type="button">Open terminal</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'review') {
|
||||
return (
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Review diff' +
|
||||
(a.pr ? ' #' + a.pr.number : '') + '</button>' +
|
||||
'<button class="btn" type="button">Mark reviewed</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'working') {
|
||||
return (
|
||||
'<div class="reply"><input type="text" placeholder="Queue a message…" aria-label="Queue a message" />' +
|
||||
'<button class="btn is-danger" type="button">Interrupt</button></div>'
|
||||
);
|
||||
}
|
||||
return '<div class="btnrow"><button class="btn" type="button">Open terminal</button></div>';
|
||||
}
|
||||
|
||||
function renderPanel(a) {
|
||||
if (!a) {
|
||||
el.panel.classList.remove('is-open');
|
||||
el.panel.setAttribute('aria-hidden', 'true');
|
||||
return;
|
||||
}
|
||||
el.panel.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')');
|
||||
el.panel.innerHTML =
|
||||
'<button class="panel-close" type="button" aria-label="Close">×</button>' +
|
||||
'<div class="panel-body">' +
|
||||
'<h2 class="p-name">' + esc(a.shortName) + '</h2>' +
|
||||
'<div class="p-state"><i class="dot"></i>' + STATE_LABEL[a.state] +
|
||||
'<span class="live">· ' + liveness(a) + '</span></div>' +
|
||||
'<p class="p-path">' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '</p>' +
|
||||
'<div class="p-facts">' +
|
||||
'<div><div class="eyebrow">Started</div><div class="v">' + clock(a.startedAt) + '</div></div>' +
|
||||
'<div><div class="eyebrow">Elapsed</div><div class="v">' + F.ageLabel(a.startedAgo) + '</div></div>' +
|
||||
'<div><div class="eyebrow">Duration</div><div class="v">' + F.ageLabel(a.durationMs) + '</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="p-tail">' + tailHtml(a) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="p-respond">' + respondHtml(a) + '</div>';
|
||||
el.panel.classList.add('is-open');
|
||||
el.panel.setAttribute('aria-hidden', 'false');
|
||||
el.panel.querySelector('.panel-close').addEventListener('click', () => select(null));
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (selectedId && nodeOf.has(selectedId)) nodeOf.get(selectedId).classList.remove('is-selected');
|
||||
selectedId = id;
|
||||
if (id && nodeOf.has(id)) nodeOf.get(id).classList.add('is-selected');
|
||||
renderPanel(id ? byId.get(id) : null);
|
||||
drawLineage();
|
||||
}
|
||||
|
||||
// ---- search ----------------------------------------------------------------
|
||||
|
||||
function applySearch(raw) {
|
||||
const q = raw.trim().toLowerCase();
|
||||
for (const a of F.agents) {
|
||||
const node = nodeOf.get(a.id);
|
||||
if (!node) continue;
|
||||
const hit =
|
||||
!q ||
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.worktree.toLowerCase().includes(q) ||
|
||||
a.displayProject.toLowerCase().includes(q) ||
|
||||
a.agentType.toLowerCase().includes(q) ||
|
||||
a.state.includes(q) ||
|
||||
STATE_LABEL[a.state].toLowerCase().includes(q);
|
||||
node.classList.toggle('is-dim', Boolean(q) && !hit);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- wiring ----------------------------------------------------------------
|
||||
|
||||
renderChrome();
|
||||
renderField();
|
||||
|
||||
el.field.addEventListener('click', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (!node) return;
|
||||
select(node.dataset.agent === selectedId ? null : node.dataset.agent);
|
||||
});
|
||||
el.field.addEventListener('mouseover', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node) showTip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.field.addEventListener('mouseout', (e) => {
|
||||
if (e.target.closest('[data-agent]')) hideTip();
|
||||
});
|
||||
el.field.addEventListener('focusin', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node) showTip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.field.addEventListener('focusout', hideTip);
|
||||
el.field.addEventListener('keydown', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
select(node.dataset.agent === selectedId ? null : node.dataset.agent);
|
||||
}
|
||||
});
|
||||
|
||||
el.search.addEventListener('input', () => applySearch(el.search.value));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key.toLowerCase() === 'k' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
el.search.focus();
|
||||
el.search.select();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (document.activeElement === el.search && el.search.value) {
|
||||
el.search.value = '';
|
||||
applySearch('');
|
||||
} else if (selectedId) {
|
||||
select(null);
|
||||
}
|
||||
hideTip();
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', drawLineage);
|
||||
|
||||
// Pre-seed the one attention agent whose parent resolves, so the first paint
|
||||
// carries the ring, a cross-section lineage line and the numbered reply panel.
|
||||
const seed =
|
||||
F.agents.find((a) => a.state === 'attention' && ((a.parent && byId.has(a.parent)) || childrenOf.has(a.id))) ||
|
||||
F.agents.find((a) => a.state === 'attention' && a.pinned) ||
|
||||
F.buckets.needsYou[0];
|
||||
select(seed ? seed.id : null);
|
||||
requestAnimationFrame(() => requestAnimationFrame(drawLineage));
|
||||
})();
|
||||
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,302 @@
|
||||
/* Fleet · Inbox — the calm view. One column, a few cards, three quiet rows.
|
||||
Hierarchy comes from size and spacing, never from borders, labels, or legends. */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.whisper-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.whisper-nav .wordmark {
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.whisper-nav a {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
margin-left: 14px;
|
||||
}
|
||||
.whisper-nav a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.inbox {
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 24px 56px;
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
|
||||
.inbox-head {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.inbox-head h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.inbox-head .sub {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ---------- cards ---------- */
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.dot-attention { background: var(--state-attention); }
|
||||
.dot-blocked { background: var(--state-blocked); }
|
||||
.dot-working { background: color-mix(in srgb, var(--state-working) 70%, transparent); }
|
||||
.dot-review { background: color-mix(in srgb, var(--state-done) 80%, transparent); }
|
||||
.dot-done { background: var(--state-idle); }
|
||||
|
||||
.card-title {
|
||||
font-size: 16.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
.card-title code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: var(--secondary);
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
.card-sub {
|
||||
margin: 5px 0 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.card-question {
|
||||
margin: 2px 0 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Buttons: one filled pill per card at most; everything else is quiet. */
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary);
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--foreground);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Overlapping mini avatars — the "4 agents waiting" at a glance. */
|
||||
.avatars {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
.avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--secondary);
|
||||
border: 2px solid var(--card);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
margin-left: -7px;
|
||||
}
|
||||
.avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Collapsed one-line cards */
|
||||
.card.is-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.card.is-compact:hover {
|
||||
background: color-mix(in srgb, var(--secondary) 55%, var(--card));
|
||||
}
|
||||
.card.is-compact .card-question {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chevron {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 15px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Resolved state: the card settles, it does not vanish. */
|
||||
.card.is-resolved {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.card.is-resolved .card-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------- the rest of the fleet: one grouped list ---------- */
|
||||
|
||||
.rest {
|
||||
margin-top: 22px;
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 52px;
|
||||
padding: 0 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.rest-row:hover {
|
||||
background: color-mix(in srgb, var(--secondary) 55%, var(--card));
|
||||
}
|
||||
.rest-row + .rest-row,
|
||||
.rest-sublist + .rest-row {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rest-label {
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rest-count {
|
||||
margin-left: auto;
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rest-row .chevron {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.rest-row.is-open .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rest-sublist {
|
||||
display: none;
|
||||
}
|
||||
.rest-row.is-open + .rest-sublist {
|
||||
display: block;
|
||||
}
|
||||
.rest-sub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
padding: 0 20px 0 38px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.rest-sub-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.rest-sub-meta {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12.5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rest-sub-more {
|
||||
color: var(--muted-foreground);
|
||||
justify-content: center;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
|
||||
.foot {
|
||||
margin-top: 28px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fleet — Inbox</title>
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-inbox.css" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="whisper-nav">
|
||||
<span class="wordmark">Fleet</span>
|
||||
<span class="nav-links">
|
||||
<a href="./fleet-hexmap.html">Hexmap</a>
|
||||
<a href="./fleet-terminal-wall.html">Wall</a>
|
||||
<a href="./mission-control.html">Mission Control</a>
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<main class="inbox">
|
||||
<header class="inbox-head">
|
||||
<h1 id="headline"></h1>
|
||||
<p class="sub" id="subline"></p>
|
||||
</header>
|
||||
|
||||
<section id="cards"></section>
|
||||
|
||||
<section class="rest" id="rest"></section>
|
||||
|
||||
<footer class="foot" id="foot"></footer>
|
||||
</main>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-inbox.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,144 @@
|
||||
/* Fleet · Inbox — renders the calm view from window.FLEET. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const F = window.FLEET;
|
||||
const attention = F.agents.filter((a) => a.state === 'attention');
|
||||
const failed = F.agents.filter((a) => a.state === 'failed');
|
||||
const review = F.agents
|
||||
.filter((a) => a.state === 'review')
|
||||
.sort((x, y) => (y.completedAt || 0) - (x.completedAt || 0));
|
||||
const working = F.buckets.working;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- header ---------------------------------------------------------------
|
||||
const items = F.decisions.length + attention.length + 1; // failures grouped as one
|
||||
const biggest = Math.max(...F.decisions.map((d) => d.agents.length));
|
||||
document.getElementById('headline').textContent = 'Needs you';
|
||||
document.getElementById('subline').textContent =
|
||||
items + ' items · one approval unblocks ' + biggest + ' agents';
|
||||
|
||||
const cards = document.getElementById('cards');
|
||||
|
||||
// ---- approval cards -------------------------------------------------------
|
||||
F.decisions.forEach((d) => {
|
||||
const card = el('article', 'card');
|
||||
const ctx = el('div', 'card-context');
|
||||
ctx.append(el('i', 'dot dot-attention'));
|
||||
const projects = [...new Set(d.agents.map((a) => a.displayProject))].join(', ');
|
||||
ctx.append(el('span', null, d.agents.length + ' agents waiting · ' + projects));
|
||||
const avatars = el('div', 'avatars');
|
||||
d.agents.slice(0, 4).forEach((a) => avatars.append(el('span', 'avatar', a.agentType[0].toUpperCase())));
|
||||
ctx.append(avatars);
|
||||
|
||||
const title = el('h2', 'card-title');
|
||||
title.append('Allow ', Object.assign(el('code'), { textContent: d.command }), '?');
|
||||
|
||||
const actions = el('div', 'card-actions');
|
||||
const deny = el('button', 'btn btn-ghost', 'Deny');
|
||||
const allow = el('button', 'btn btn-primary', 'Allow');
|
||||
allow.addEventListener('click', () => {
|
||||
card.classList.add('is-resolved');
|
||||
ctx.lastChild.remove();
|
||||
title.textContent = 'Allowed ' + d.command;
|
||||
card.querySelector('.card-sub')?.remove();
|
||||
title.after(el('p', 'card-sub', d.agents.length + ' agents resuming'));
|
||||
});
|
||||
actions.append(el('span', 'spacer'), deny, allow);
|
||||
|
||||
card.append(ctx, title, el('p', 'card-sub', d.detail), actions);
|
||||
cards.append(card);
|
||||
});
|
||||
|
||||
// ---- question cards (first expanded, rest one line) -----------------------
|
||||
attention.forEach((a, i) => {
|
||||
const card = el('article', 'card' + (i === 0 ? '' : ' is-compact'));
|
||||
if (i === 0) {
|
||||
const ctx = el('div', 'card-context');
|
||||
ctx.append(el('i', 'dot dot-attention'), el('span', null, a.shortName));
|
||||
const q = el('p', 'card-question', a.question.text);
|
||||
const actions = el('div', 'card-actions');
|
||||
a.question.options.forEach((opt) => {
|
||||
const b = el('button', 'btn', opt);
|
||||
b.addEventListener('click', () => {
|
||||
card.classList.add('is-resolved');
|
||||
q.textContent = 'Answered — ' + opt;
|
||||
});
|
||||
actions.append(b);
|
||||
});
|
||||
actions.append(el('button', 'btn btn-ghost', 'Reply…'));
|
||||
card.append(ctx, q, actions);
|
||||
} else {
|
||||
card.append(
|
||||
el('i', 'dot dot-attention'),
|
||||
el('p', 'card-question', a.question.text),
|
||||
el('span', 'chevron', '›'),
|
||||
);
|
||||
card.addEventListener('click', () => card.classList.add('is-resolved'));
|
||||
}
|
||||
cards.append(card);
|
||||
});
|
||||
|
||||
// ---- failures, grouped into one card --------------------------------------
|
||||
const fcard = el('article', 'card is-compact');
|
||||
fcard.append(
|
||||
el('i', 'dot dot-blocked'),
|
||||
el('p', 'card-question', failed.length + ' agents stopped — ' + failed[0].failure),
|
||||
el('span', 'chevron', '›'),
|
||||
);
|
||||
cards.append(fcard);
|
||||
|
||||
// ---- the rest of the fleet ------------------------------------------------
|
||||
const rest = document.getElementById('rest');
|
||||
function row(dotCls, label, count, subRows) {
|
||||
const r = el('div', 'rest-row');
|
||||
r.append(el('i', 'dot ' + dotCls), el('span', 'rest-label', label), el('span', 'rest-count', String(count)), el('span', 'chevron', '›'));
|
||||
rest.append(r);
|
||||
if (subRows) {
|
||||
const list = el('div', 'rest-sublist');
|
||||
list.append(...subRows);
|
||||
rest.append(list);
|
||||
r.addEventListener('click', () => r.classList.toggle('is-open'));
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
row(
|
||||
'dot-working',
|
||||
'Working',
|
||||
working.length,
|
||||
working.slice(0, 5).map((a) => {
|
||||
const s = el('div', 'rest-sub-row');
|
||||
s.append(el('span', 'rest-sub-name', a.shortName), el('span', 'rest-sub-meta', a.detail || 'Working'));
|
||||
return s;
|
||||
}).concat(working.length > 5 ? [el('div', 'rest-sub-row rest-sub-more', working.length - 5 + ' more')] : []),
|
||||
);
|
||||
|
||||
row(
|
||||
'dot-review',
|
||||
'Ready to review',
|
||||
review.length,
|
||||
review.slice(0, 5).map((a) => {
|
||||
const s = el('div', 'rest-sub-row');
|
||||
s.append(
|
||||
el('span', 'rest-sub-name', a.shortName),
|
||||
el('span', 'rest-sub-meta', (a.pr ? '#' + a.pr.number + ' · ' : '') + F.ageLabel(a.durationMs) + ' run'),
|
||||
);
|
||||
return s;
|
||||
}).concat(review.length > 5 ? [el('div', 'rest-sub-row rest-sub-more', review.length - 5 + ' more')] : []),
|
||||
);
|
||||
|
||||
row('dot-done', 'Done', F.buckets.done.length, null);
|
||||
|
||||
// ---- footer ---------------------------------------------------------------
|
||||
const t = new Date(F.now);
|
||||
const hh = t.getUTCHours() % 12 || 12;
|
||||
const mm = String(t.getUTCMinutes()).padStart(2, '0');
|
||||
document.getElementById('foot').textContent =
|
||||
F.counts.fleetTotal + ' agents across ' + F.counts.fleetWorktrees + ' worktrees · updated ' + hh + ':' + mm + ' PM';
|
||||
})();
|
||||
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,591 @@
|
||||
/* Fleet containment map — deterministic Nomad-style containment.
|
||||
Project band → worktree cell → agent glyph. Document order is alphabetical and
|
||||
NEVER re-sorted by state: an agent keeps its slot so muscle memory survives a refresh. */
|
||||
|
||||
.svg-defs { position: absolute; width: 0; height: 0; }
|
||||
.icon {
|
||||
width: 13px; height: 13px; flex: none;
|
||||
fill: none; stroke: currentColor; stroke-width: 1.6;
|
||||
stroke-linecap: round; stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.shell {
|
||||
height: 100vh;
|
||||
min-width: 1280px;
|
||||
padding: 11px 14px 12px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- top bar ---------------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.viewtabs { display: flex; gap: 2px; }
|
||||
.viewtabs a {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
.viewtabs a:hover { color: var(--foreground); background: var(--muted); }
|
||||
.viewtabs a.is-active { color: var(--foreground); background: var(--secondary); }
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 300px;
|
||||
padding: 0 8px;
|
||||
height: 27px;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--card);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.search:focus-within { border-color: var(--ring); }
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.search input::placeholder { color: var(--muted-foreground); }
|
||||
.search input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.match-chip {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--secondary);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accounting {
|
||||
margin: 0 0 0 auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.accounting b { color: var(--foreground); font-weight: 550; }
|
||||
|
||||
/* ---------- pulse dials ------------------------------------------------ */
|
||||
|
||||
.dials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.dial {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 8px 12px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-lift);
|
||||
}
|
||||
.dial strong {
|
||||
font-size: 26px;
|
||||
font-weight: 650;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.dial .eyebrow { line-height: 1.4; }
|
||||
|
||||
/* Steady annunciation, never blinking — the blink budget belongs to the cards. */
|
||||
.dial.is-needs {
|
||||
border-color: color-mix(in srgb, var(--state-attention) 55%, var(--border));
|
||||
box-shadow: var(--shadow-lift), 0 0 12px color-mix(in srgb, var(--state-attention) 18%, transparent);
|
||||
}
|
||||
.dial.is-review { border-color: color-mix(in srgb, var(--state-done) 40%, var(--border)); }
|
||||
/* A zero dial drops all tint — nothing to annunciate. */
|
||||
.dial.is-zero,
|
||||
.dial.is-needs.is-zero,
|
||||
.dial.is-review.is-zero { border-color: var(--border); box-shadow: var(--shadow-lift); }
|
||||
.dial.is-zero strong { color: var(--muted-foreground); }
|
||||
|
||||
.dial-head { display: flex; align-items: baseline; gap: 6px; }
|
||||
.delta {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- workspace split -------------------------------------------- */
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 428px;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
.bands {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
/* ---------- project band (group annunciator) --------------------------- */
|
||||
|
||||
.band {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
padding: 8px 10px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.band { flex: 0 1 auto; }
|
||||
.band.grows { flex: 1 1 auto; }
|
||||
/* A squeezed band must still show one full cell row; two rows max before it scrolls. */
|
||||
.band:not(.grows) .band-cells { min-height: 72px; max-height: 152px; }
|
||||
.band-head { display: flex; align-items: center; gap: 8px; }
|
||||
.band-head .eyebrow { letter-spacing: 0.08em; }
|
||||
.band-head .band-meta { margin-left: auto; font-size: 10px; color: var(--muted-foreground); }
|
||||
|
||||
/* Tint carries the band's worst state at low intensity. */
|
||||
.band.worst-attention,
|
||||
.band.worst-permission { border-color: color-mix(in srgb, var(--state-attention) 35%, var(--border)); }
|
||||
.band.worst-failed { border-color: color-mix(in srgb, var(--state-blocked) 35%, var(--border)); }
|
||||
.band.worst-working { border-color: color-mix(in srgb, var(--state-working) 28%, var(--border)); }
|
||||
.band.worst-review { border-color: color-mix(in srgb, var(--state-done) 30%, var(--border)); }
|
||||
|
||||
/* Group annunciation: someone inside is waiting on you. */
|
||||
@keyframes band-annunciate {
|
||||
0%, 100% { border-color: color-mix(in srgb, var(--state-attention) 45%, var(--border)); }
|
||||
50% { border-color: color-mix(in srgb, var(--state-attention) 10%, var(--border)); }
|
||||
}
|
||||
.band.worst-attention,
|
||||
.band.worst-permission { animation: band-annunciate var(--pulse-slow) ease-in-out infinite; }
|
||||
/* Severity-ordered: a band containing a failure annunciates too, in red. */
|
||||
@keyframes band-annunciate-failed {
|
||||
0%, 100% { border-color: color-mix(in srgb, var(--state-blocked) 45%, var(--border)); }
|
||||
50% { border-color: color-mix(in srgb, var(--state-blocked) 12%, var(--border)); }
|
||||
}
|
||||
.band.worst-failed { animation: band-annunciate-failed var(--pulse-slow) ease-in-out infinite; }
|
||||
|
||||
.band-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
grid-auto-rows: min-content;
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--accent) transparent;
|
||||
}
|
||||
.band-cells::-webkit-scrollbar { width: 8px; }
|
||||
.band-cells::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 4px; }
|
||||
|
||||
.band-empty { font-size: 11.5px; color: var(--muted-foreground); padding: 6px 2px; }
|
||||
|
||||
/* ---------- worktree cell ---------------------------------------------- */
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 6px 7px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--card);
|
||||
}
|
||||
.cell.worst-attention,
|
||||
.cell.worst-permission { border-color: color-mix(in srgb, var(--state-attention) 30%, var(--border)); }
|
||||
.cell.worst-failed { border-color: color-mix(in srgb, var(--state-blocked) 30%, var(--border)); }
|
||||
.cell.worst-working { border-color: color-mix(in srgb, var(--state-working) 22%, var(--border)); }
|
||||
.cell.worst-review { border-color: color-mix(in srgb, var(--state-done) 24%, var(--border)); }
|
||||
|
||||
.cell-head { display: flex; align-items: center; gap: 5px; }
|
||||
.cell-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.unread-dot {
|
||||
flex: none;
|
||||
width: 4px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
.cell-count {
|
||||
flex: none;
|
||||
min-width: 12px;
|
||||
text-align: right;
|
||||
font-size: 10px;
|
||||
font-weight: 550;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.cell-glyphs { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
|
||||
/* ---------- agent glyph ------------------------------------------------ */
|
||||
|
||||
.glyph {
|
||||
--gbg: color-mix(in srgb, var(--sc) 14%, var(--card));
|
||||
position: relative;
|
||||
width: 16px; height: 16px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1.5px solid var(--sc);
|
||||
border-radius: 5px;
|
||||
background: var(--gbg);
|
||||
color: var(--sc);
|
||||
cursor: default;
|
||||
font: inherit;
|
||||
}
|
||||
button.glyph { cursor: pointer; }
|
||||
button.glyph:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
.glyph .g { font-size: 10px; line-height: 1; display: block; }
|
||||
|
||||
/* Working is deliberately quiet so amber/red are the only loud warm hues. */
|
||||
.glyph.st-working { opacity: 0.65; }
|
||||
.glyph.st-done { opacity: 0.8; }
|
||||
|
||||
/* Liveness is a separate channel from state: filled = process live, outline-only = exited. */
|
||||
.glyph.is-dead {
|
||||
--gbg: transparent;
|
||||
border-color: color-mix(in srgb, var(--sc) 50%, transparent);
|
||||
color: color-mix(in srgb, var(--sc) 60%, transparent);
|
||||
}
|
||||
|
||||
.g.ring {
|
||||
width: 9px; height: 9px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 3.6s linear infinite;
|
||||
}
|
||||
.g.dot { width: 4px; height: 4px; border-radius: 50%; background: currentColor; }
|
||||
.g.crescent {
|
||||
position: relative;
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
.g.crescent::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -1px -2px -1px 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
/* Motion grammar: fast blink = unacknowledged needs-you, and nothing else. */
|
||||
.glyph.blink-attention { animation: blink-border-attention var(--blink-fast) infinite; }
|
||||
.glyph.blink-blocked { animation: blink-border-blocked var(--blink-fast) infinite; }
|
||||
/* Slow pulse = finished, waiting on your review. */
|
||||
.glyph.pulse-review { animation: pulse-review var(--pulse-slow) ease-in-out infinite; }
|
||||
|
||||
.glyph.is-selected { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
.glyph.is-kin { outline: 1px solid color-mix(in srgb, var(--ring) 65%, transparent); outline-offset: 2px; }
|
||||
|
||||
/* Search never removes or reflows a glyph — it only drains it of interest. */
|
||||
.cell.dim, .glyph.dim { filter: grayscale(1); opacity: 0.25; }
|
||||
.glyph.dim { animation: none; }
|
||||
|
||||
/* ---------- lineage overlay -------------------------------------------- */
|
||||
|
||||
.lineage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lineage path {
|
||||
fill: none;
|
||||
stroke: var(--ring);
|
||||
stroke-width: 1;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.lineage circle { fill: var(--ring); opacity: 0.55; }
|
||||
|
||||
/* ---------- legend ------------------------------------------------------ */
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; gap: 5px; }
|
||||
.legend-motion { margin-left: auto; }
|
||||
|
||||
/* ---------- detail panel ------------------------------------------------ */
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 11px 12px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--accent) transparent;
|
||||
}
|
||||
.detail::-webkit-scrollbar { width: 8px; }
|
||||
.detail::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 4px; }
|
||||
|
||||
.detail-empty {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.detail-empty strong { display: block; font-weight: 550; color: var(--foreground); margin-bottom: 3px; }
|
||||
|
||||
.detail-name {
|
||||
margin: 3px 0 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.detail-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.detail-state small { color: var(--muted-foreground); }
|
||||
.detail-path {
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--muted-foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.facts div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.facts b {
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tail {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tail .last { color: var(--foreground); }
|
||||
|
||||
.respond { margin-top: 11px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.respond .question {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.respond .failure {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
.cmd {
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.options { display: flex; flex-direction: column; gap: 5px; }
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.opt:hover { border-color: var(--input); background: var(--accent); }
|
||||
.opt kbd { flex: none; }
|
||||
|
||||
.reply-row { display: flex; gap: 6px; }
|
||||
.reply-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 29px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.reply-row input::placeholder { color: var(--muted-foreground); }
|
||||
.reply-row input:focus { border-color: var(--ring); }
|
||||
|
||||
.btn-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.btn {
|
||||
height: 29px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--accent); }
|
||||
.btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border-color: var(--primary);
|
||||
font-weight: 550;
|
||||
}
|
||||
.btn.primary:hover { background: var(--foreground); }
|
||||
.btn.ghost { background: none; color: var(--muted-foreground); }
|
||||
.btn.ghost:hover { background: var(--muted); color: var(--foreground); }
|
||||
|
||||
.pr-chip {
|
||||
align-self: flex-start;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--secondary);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- tooltip (the only floating layer) --------------------------- */
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
width: 268px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--popover);
|
||||
box-shadow: var(--shadow-floating);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tooltip[hidden] { display: none; }
|
||||
.tooltip .tt-name {
|
||||
font-size: 11.5px;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.tooltip .tt-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.tooltip dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 2px 10px;
|
||||
margin: 7px 0 0;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tooltip dt { color: var(--muted-foreground); }
|
||||
.tooltip dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Fleet — Containment Map</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-map.css" />
|
||||
</head>
|
||||
<body>
|
||||
<svg class="svg-defs" aria-hidden="true">
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7.5" /><path d="m20.5 20.5-4-4" /></symbol>
|
||||
</svg>
|
||||
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<h1 class="brand">Fleet</h1>
|
||||
<nav class="viewtabs" aria-label="Fleet views">
|
||||
<a class="is-active" href="./fleet-map.html" aria-current="page">Map</a>
|
||||
<a href="./fleet-strips.html">Strips</a>
|
||||
<a href="./mission-control.html">Mission Control</a>
|
||||
</nav>
|
||||
<label class="search">
|
||||
<svg class="icon" aria-hidden="true"><use href="#i-search" /></svg>
|
||||
<input id="search" type="search" placeholder="Search agents, worktrees, states…" autocomplete="off" spellcheck="false" aria-label="Search the fleet" />
|
||||
<kbd id="search-kbd">⌘K</kbd>
|
||||
</label>
|
||||
<span class="match-chip" id="match-chip" hidden></span>
|
||||
<p class="accounting" id="accounting"></p>
|
||||
</header>
|
||||
|
||||
<section class="dials" id="dials" aria-label="Fleet pulse"></section>
|
||||
|
||||
<div class="workspace">
|
||||
<section class="canvas" aria-label="Containment map">
|
||||
<div class="bands" id="bands"></div>
|
||||
<svg class="lineage" id="lineage" aria-hidden="true"></svg>
|
||||
<footer class="legend" id="legend"></footer>
|
||||
</section>
|
||||
|
||||
<aside class="detail" id="detail" aria-live="polite"></aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="tooltip" id="tooltip" role="tooltip" hidden></div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-map.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,438 @@
|
||||
/* Containment map. Everything on screen is derived from window.FLEET; ages come from
|
||||
FLEET.now (the capture time) so the render is byte-identical on every reload. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const F = window.FLEET;
|
||||
if (!F) throw new Error('fleet-state-synthesis.js must be loaded first');
|
||||
|
||||
const IS_MAC = navigator.userAgent.includes('Mac');
|
||||
const MOD = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
|
||||
const STATE_LABEL = {
|
||||
attention: 'Needs an answer',
|
||||
permission: 'Awaiting permission',
|
||||
failed: 'Failed',
|
||||
working: 'Working',
|
||||
review: 'Ready to review',
|
||||
done: 'Done',
|
||||
};
|
||||
const NEEDS_YOU = { attention: 1, permission: 1, failed: 1 };
|
||||
// FLEET maps done → --state-idle; the spec wants a quiet gray-emerald, so mix, don't invent.
|
||||
const DONE_COLOR = 'color-mix(in srgb, var(--state-done) 32%, var(--ring))';
|
||||
|
||||
const byId = new Map(F.agents.map((a) => [a.id, a]));
|
||||
const childrenOf = new Map();
|
||||
for (const a of F.agents) {
|
||||
if (a.parent && byId.has(a.parent)) {
|
||||
if (!childrenOf.has(a.parent)) childrenOf.set(a.parent, []);
|
||||
childrenOf.get(a.parent).push(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
const el = {
|
||||
accounting: document.getElementById('accounting'),
|
||||
dials: document.getElementById('dials'),
|
||||
bands: document.getElementById('bands'),
|
||||
lineage: document.getElementById('lineage'),
|
||||
legend: document.getElementById('legend'),
|
||||
detail: document.getElementById('detail'),
|
||||
tooltip: document.getElementById('tooltip'),
|
||||
search: document.getElementById('search'),
|
||||
searchKbd: document.getElementById('search-kbd'),
|
||||
matchChip: document.getElementById('match-chip'),
|
||||
canvas: document.querySelector('.canvas'),
|
||||
};
|
||||
|
||||
let selectedId = null;
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
||||
}
|
||||
function colorFor(state) {
|
||||
return state === 'done' ? DONE_COLOR : 'var(' + F.stateColorVar(state) + ')';
|
||||
}
|
||||
function livenessLabel(a) {
|
||||
if (a.loopSleeping) return 'loop sleeping';
|
||||
return a.alive ? 'process live' : 'process exited';
|
||||
}
|
||||
function clockLabel(ms) {
|
||||
const d = new Date(ms);
|
||||
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
|
||||
// ---- glyph -----------------------------------------------------------------
|
||||
|
||||
function glyphBody(a) {
|
||||
if (a.loopSleeping) return '<span class="g crescent"></span>';
|
||||
if (a.state === 'working') return '<span class="g ring"></span>';
|
||||
if (a.state === 'done') return '<span class="g dot"></span>';
|
||||
return '<span class="g">' + esc(F.stateGlyph(a.state)) + '</span>';
|
||||
}
|
||||
|
||||
function glyphClasses(a) {
|
||||
const c = ['glyph', 'st-' + a.state];
|
||||
if (!a.alive) c.push('is-dead');
|
||||
// Only an UNACKNOWLEDGED needs-you agent gets the fast blink.
|
||||
if (NEEDS_YOU[a.state] && a.unread) c.push(a.state === 'failed' ? 'blink-blocked' : 'blink-attention');
|
||||
if (a.state === 'review') c.push('pulse-review');
|
||||
return c.join(' ');
|
||||
}
|
||||
|
||||
function glyphHtml(a, interactive) {
|
||||
const tag = interactive ? 'button' : 'span';
|
||||
return (
|
||||
'<' + tag + ' class="' + glyphClasses(a) + '" style="--sc:' + colorFor(a.state) + '"' +
|
||||
(interactive ? ' data-agent="' + esc(a.id) + '" aria-label="' + esc(a.shortName) + '"' : ' aria-hidden="true"') +
|
||||
'>' + glyphBody(a) + '</' + tag + '>'
|
||||
);
|
||||
}
|
||||
|
||||
// Legend/detail need a glyph for a state with no particular agent behind it.
|
||||
function stateChip(state) {
|
||||
const fake = { state, alive: true, loopSleeping: false, unread: false, shortName: '' };
|
||||
return '<span class="glyph st-' + state + '" style="--sc:' + colorFor(state) + '" aria-hidden="true">' + glyphBody(fake) + '</span>';
|
||||
}
|
||||
|
||||
// ---- top bar + dials -------------------------------------------------------
|
||||
|
||||
function renderChrome() {
|
||||
el.searchKbd.textContent = MOD + 'K';
|
||||
el.accounting.innerHTML =
|
||||
'<b>' + F.counts.total + '</b> of ' + F.counts.fleetTotal + ' agents · ' +
|
||||
'<b>' + F.byWorktree.length + '</b> of ' + F.counts.fleetWorktrees + ' worktrees · captured ' +
|
||||
new Date(F.now).toISOString().slice(11, 16) + ' UTC';
|
||||
|
||||
const dials = [
|
||||
{ key: 'needsYou', label: 'Needs you', n: F.counts.needsYou, cls: 'is-needs' },
|
||||
{ key: 'working', label: 'Working', n: F.counts.working, cls: '' },
|
||||
{ key: 'review', label: 'To review', n: F.counts.review, cls: 'is-review', delta: true },
|
||||
{ key: 'done', label: 'Done', n: F.counts.done, cls: '' },
|
||||
];
|
||||
el.dials.innerHTML = dials
|
||||
.map(
|
||||
(d) =>
|
||||
'<article class="dial ' + d.cls + (d.n === 0 ? ' is-zero' : '') + '">' +
|
||||
'<span class="dial-head"><strong>' + d.n + '</strong>' +
|
||||
(d.delta && d.n > 0 ? '<span class="delta" aria-label="trending up">↑</span>' : '') +
|
||||
'</span>' +
|
||||
'<span class="eyebrow">' + esc(d.label) + '</span>' +
|
||||
'</article>'
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// ---- bands -----------------------------------------------------------------
|
||||
|
||||
function renderBands() {
|
||||
el.bands.innerHTML = F.projects
|
||||
.map((p) => {
|
||||
const cells = p.worktrees
|
||||
.map(
|
||||
(g) =>
|
||||
'<article class="cell worst-' + g.worstState + '" data-worktree="' + esc(g.project + '/' + g.worktree) + '">' +
|
||||
'<header class="cell-head">' +
|
||||
'<span class="cell-name" title="' + esc(g.worktree) + '">' + esc(g.worktree) + '</span>' +
|
||||
(g.unread ? '<i class="unread-dot" title="unread output"></i>' : '') +
|
||||
'<b class="cell-count">' + g.agents.length + '</b>' +
|
||||
'</header>' +
|
||||
'<div class="cell-glyphs">' + g.agents.map((a) => glyphHtml(a, true)).join('') + '</div>' +
|
||||
'</article>'
|
||||
)
|
||||
.join('');
|
||||
return (
|
||||
'<section class="band worst-' + p.worstState + (p.name === 'orca' ? ' grows' : '') + '">' +
|
||||
'<header class="band-head">' +
|
||||
'<span class="eyebrow">' + esc(p.name) + '</span>' +
|
||||
'<span class="band-meta">' + p.worktrees.length + ' worktrees · ' + p.agents.length + ' agents</span>' +
|
||||
'</header>' +
|
||||
'<div class="band-cells">' + (cells || '<p class="band-empty">No worktrees in this project.</p>') + '</div>' +
|
||||
'</section>'
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
const states = ['attention', 'permission', 'failed', 'working', 'review', 'done'];
|
||||
el.legend.innerHTML =
|
||||
states
|
||||
.map((s) => '<span class="legend-item">' + stateChip(s) + esc(STATE_LABEL[s]) + '</span>')
|
||||
.join('') +
|
||||
'<span class="legend-motion">fast blink = needs you · slow pulse = awaiting review · still = settled</span>';
|
||||
}
|
||||
|
||||
// ---- tooltip ---------------------------------------------------------------
|
||||
|
||||
function showTooltip(node, a) {
|
||||
el.tooltip.innerHTML =
|
||||
'<p class="tt-name">' + esc(a.shortName) + '</p>' +
|
||||
'<p class="tt-state">' + stateChip(a.state) + esc(STATE_LABEL[a.state]) + ' · ' + esc(livenessLabel(a)) + '</p>' +
|
||||
'<dl>' +
|
||||
'<dt>Runner</dt><dd>' + esc(a.agentType) + '</dd>' +
|
||||
'<dt>Started</dt><dd>' + esc(F.ageLabel(a.startedAgo)) + ' ago</dd>' +
|
||||
'<dt>Duration</dt><dd>' + esc(F.ageLabel(a.durationMs)) + '</dd>' +
|
||||
'<dt>Doing</dt><dd>' + esc(a.detail || '—') + '</dd>' +
|
||||
'</dl>';
|
||||
el.tooltip.hidden = false;
|
||||
const r = node.getBoundingClientRect();
|
||||
const t = el.tooltip.getBoundingClientRect();
|
||||
let left = r.left + r.width / 2 - t.width / 2;
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - t.width - 8));
|
||||
let top = r.bottom + 8;
|
||||
if (top + t.height > window.innerHeight - 8) top = r.top - t.height - 8;
|
||||
el.tooltip.style.left = left + 'px';
|
||||
el.tooltip.style.top = top + 'px';
|
||||
}
|
||||
function hideTooltip() {
|
||||
el.tooltip.hidden = true;
|
||||
}
|
||||
|
||||
// ---- detail panel ----------------------------------------------------------
|
||||
|
||||
function respondHtml(a) {
|
||||
if (a.state === 'attention' && a.question) {
|
||||
return (
|
||||
'<span class="eyebrow">Respond in place</span>' +
|
||||
'<p class="question">' + esc(a.question.text) + '</p>' +
|
||||
'<div class="options">' +
|
||||
a.question.options
|
||||
.map((o, i) => '<button class="opt" type="button"><kbd>' + (i + 1) + '</kbd><span>' + esc(o) + '</span></button>')
|
||||
.join('') +
|
||||
'</div>' +
|
||||
'<div class="reply-row"><input placeholder="Reply…" aria-label="Reply to this agent" />' +
|
||||
'<button class="btn primary" type="button">Send</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'permission') {
|
||||
const d = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
return (
|
||||
'<span class="eyebrow">Permission requested</span>' +
|
||||
(d ? '<code class="cmd">' + esc(d.command) + '</code><p class="question">' + esc(d.detail) + '</p>' : '') +
|
||||
'<div class="btn-row"><button class="btn" type="button">Allow once</button>' +
|
||||
'<button class="btn" type="button">Allow for project</button>' +
|
||||
'<button class="btn ghost" type="button">Deny</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'failed') {
|
||||
return (
|
||||
'<span class="eyebrow">Failure</span>' +
|
||||
'<p class="failure">' + esc(a.failure || 'Process exited unexpectedly.') + '</p>' +
|
||||
'<div class="btn-row"><button class="btn" type="button">Retry</button>' +
|
||||
'<button class="btn ghost" type="button">Open terminal</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'review') {
|
||||
return (
|
||||
'<span class="eyebrow">Finished · awaiting review</span>' +
|
||||
(a.pr ? '<span class="pr-chip">PR #' + a.pr.number + ' · ' + esc(a.pr.state) + '</span>' : '') +
|
||||
'<div class="btn-row"><button class="btn primary" type="button">Review diff</button>' +
|
||||
'<button class="btn" type="button">Mark reviewed</button>' +
|
||||
'<button class="btn ghost" type="button">Reopen</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'working') {
|
||||
return (
|
||||
'<span class="eyebrow">In flight</span>' +
|
||||
'<div class="reply-row"><input placeholder="Queue a message…" aria-label="Queue a message" />' +
|
||||
'<button class="btn ghost" type="button">Interrupt</button></div>'
|
||||
);
|
||||
}
|
||||
return (
|
||||
'<span class="eyebrow">Settled</span>' +
|
||||
(a.pr ? '<span class="pr-chip">PR #' + a.pr.number + ' · ' + esc(a.pr.state) + '</span>' : '') +
|
||||
'<div class="btn-row"><button class="btn ghost" type="button">Open terminal</button></div>'
|
||||
);
|
||||
}
|
||||
|
||||
const TAIL_LINES = 9;
|
||||
|
||||
function renderDetail() {
|
||||
if (!selectedId) {
|
||||
el.detail.innerHTML =
|
||||
'<p class="detail-empty"><strong>Select an agent</strong>click any glyph</p>';
|
||||
return;
|
||||
}
|
||||
const a = byId.get(selectedId);
|
||||
const tail = F.fakeTail(a);
|
||||
const pad = Math.max(0, TAIL_LINES - tail.length);
|
||||
const tailHtml =
|
||||
Array(pad).fill('').concat(tail).map((line, i, all) =>
|
||||
i === all.length - 1 ? '<span class="last">' + esc(line) + '</span>' : esc(line)
|
||||
).join('\n');
|
||||
|
||||
el.detail.innerHTML =
|
||||
'<span class="eyebrow">Selected agent</span>' +
|
||||
'<h2 class="detail-name">' + esc(a.name) + '</h2>' +
|
||||
'<p class="detail-state">' + stateChip(a.state) + esc(STATE_LABEL[a.state]) +
|
||||
'<small>· ' + esc(livenessLabel(a)) + '</small></p>' +
|
||||
'<p class="detail-path">' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '</p>' +
|
||||
'<div class="facts">' +
|
||||
'<div><span class="eyebrow">Started</span><b>' + clockLabel(a.startedAt) + '</b></div>' +
|
||||
'<div><span class="eyebrow">Elapsed</span><b>' + esc(F.ageLabel(a.startedAgo)) + '</b></div>' +
|
||||
'<div><span class="eyebrow">Duration</span><b>' + esc(F.ageLabel(a.durationMs)) + '</b></div>' +
|
||||
'<div><span class="eyebrow">Runner</span><b>' + esc(a.agentType) + '</b></div>' +
|
||||
'</div>' +
|
||||
'<pre class="tail">' + tailHtml + '</pre>' +
|
||||
'<div class="respond">' + respondHtml(a) + '</div>';
|
||||
}
|
||||
|
||||
// ---- lineage overlay -------------------------------------------------------
|
||||
|
||||
// Selector lookups on ids/worktree names full of #, spaces and em-dashes are fragile;
|
||||
// index the nodes once instead.
|
||||
const glyphNodes = new Map();
|
||||
const cellNodes = new Map();
|
||||
function indexNodes() {
|
||||
glyphNodes.clear();
|
||||
cellNodes.clear();
|
||||
el.bands.querySelectorAll('[data-agent]').forEach((n) => glyphNodes.set(n.dataset.agent, n));
|
||||
el.bands.querySelectorAll('[data-worktree]').forEach((n) => cellNodes.set(n.dataset.worktree, n));
|
||||
}
|
||||
function glyphNode(id) {
|
||||
return glyphNodes.get(id) || null;
|
||||
}
|
||||
|
||||
function drawLineage() {
|
||||
el.lineage.innerHTML = '';
|
||||
el.bands.querySelectorAll('.glyph.is-kin').forEach((n) => n.classList.remove('is-kin'));
|
||||
if (!selectedId) return;
|
||||
|
||||
const from = glyphNode(selectedId);
|
||||
if (!from) return;
|
||||
const sel = byId.get(selectedId);
|
||||
const kin = (childrenOf.get(selectedId) || []).slice();
|
||||
if (sel.parent && byId.has(sel.parent)) kin.unshift(sel.parent);
|
||||
if (!kin.length) return;
|
||||
|
||||
const base = el.canvas.getBoundingClientRect();
|
||||
el.lineage.setAttribute('viewBox', '0 0 ' + base.width + ' ' + base.height);
|
||||
const center = (n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return { x: r.left - base.left + r.width / 2, y: r.top - base.top + r.height / 2 };
|
||||
};
|
||||
const a = center(from);
|
||||
let svg = '';
|
||||
for (const id of kin) {
|
||||
const node = glyphNode(id);
|
||||
if (!node) continue;
|
||||
node.classList.add('is-kin');
|
||||
const b = center(node);
|
||||
const bow = Math.max(30, Math.abs(b.x - a.x) * 0.4);
|
||||
const dir = b.x >= a.x ? 1 : -1;
|
||||
svg +=
|
||||
'<path d="M ' + a.x + ' ' + a.y + ' C ' + (a.x + bow * dir) + ' ' + a.y + ', ' +
|
||||
(b.x - bow * dir) + ' ' + b.y + ', ' + b.x + ' ' + b.y + '" />' +
|
||||
'<circle cx="' + b.x + '" cy="' + b.y + '" r="1.6" />';
|
||||
}
|
||||
el.lineage.innerHTML = svg;
|
||||
}
|
||||
|
||||
// ---- selection -------------------------------------------------------------
|
||||
|
||||
function select(id, opts) {
|
||||
selectedId = id;
|
||||
el.bands.querySelectorAll('.glyph.is-selected').forEach((n) => n.classList.remove('is-selected'));
|
||||
const node = id && glyphNode(id);
|
||||
if (node) node.classList.add('is-selected');
|
||||
renderDetail();
|
||||
if (opts && opts.reveal && node) {
|
||||
node.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
const sel = byId.get(id);
|
||||
const kinId = sel.parent && byId.has(sel.parent) ? sel.parent : (childrenOf.get(id) || [])[0];
|
||||
const kinNode = kinId && glyphNode(kinId);
|
||||
if (kinNode) kinNode.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
requestAnimationFrame(drawLineage);
|
||||
}
|
||||
|
||||
// ---- search ----------------------------------------------------------------
|
||||
|
||||
function applySearch(raw) {
|
||||
const q = raw.trim().toLowerCase();
|
||||
let matches = 0;
|
||||
for (const g of F.byWorktree) {
|
||||
const cell = cellNodes.get(g.project + '/' + g.worktree);
|
||||
if (!cell) continue;
|
||||
let cellHits = 0;
|
||||
for (const a of g.agents) {
|
||||
const node = glyphNode(a.id);
|
||||
if (!node) continue;
|
||||
const hit =
|
||||
!q ||
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.worktree.toLowerCase().includes(q) ||
|
||||
a.displayProject.toLowerCase().includes(q) ||
|
||||
a.state.includes(q) ||
|
||||
STATE_LABEL[a.state].toLowerCase().includes(q);
|
||||
node.classList.toggle('dim', Boolean(q) && !hit);
|
||||
if (hit) cellHits++;
|
||||
if (q && hit) matches++;
|
||||
}
|
||||
cell.classList.toggle('dim', Boolean(q) && cellHits === 0);
|
||||
}
|
||||
if (!q) {
|
||||
el.matchChip.hidden = true;
|
||||
} else {
|
||||
el.matchChip.hidden = false;
|
||||
el.matchChip.textContent = matches + (matches === 1 ? ' match' : ' matches');
|
||||
}
|
||||
drawLineage();
|
||||
}
|
||||
|
||||
// ---- wiring ----------------------------------------------------------------
|
||||
|
||||
renderChrome();
|
||||
renderBands();
|
||||
indexNodes();
|
||||
renderLegend();
|
||||
|
||||
el.bands.addEventListener('click', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (!node) return;
|
||||
select(node.dataset.agent === selectedId ? null : node.dataset.agent);
|
||||
});
|
||||
el.bands.addEventListener('mouseover', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node) showTooltip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.bands.addEventListener('mouseout', (e) => {
|
||||
if (e.target.closest('[data-agent]')) hideTooltip();
|
||||
});
|
||||
el.bands.addEventListener('focusin', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node) showTooltip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.bands.addEventListener('focusout', hideTooltip);
|
||||
// Bands scroll independently, so the overlay geometry has to be recomputed.
|
||||
el.bands.addEventListener('scroll', drawLineage, true);
|
||||
window.addEventListener('resize', drawLineage);
|
||||
|
||||
el.search.addEventListener('input', () => applySearch(el.search.value));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key.toLowerCase() === 'k' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
el.search.focus();
|
||||
el.search.select();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (document.activeElement === el.search && el.search.value) {
|
||||
el.search.value = '';
|
||||
applySearch('');
|
||||
} else if (selectedId) {
|
||||
select(null);
|
||||
}
|
||||
hideTooltip();
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-seed: an attention agent with resolvable lineage, so the first paint shows the
|
||||
// selection ring, a lineage edge, and the numbered-reply panel together.
|
||||
const seed =
|
||||
F.agents.find((a) => a.state === 'attention' && ((a.parent && byId.has(a.parent)) || childrenOf.has(a.id))) ||
|
||||
F.agents.find((a) => a.state === 'attention' && a.pinned) ||
|
||||
F.buckets.needsYou[0];
|
||||
select(seed ? seed.id : null, { reveal: true });
|
||||
requestAnimationFrame(() => requestAnimationFrame(drawLineage));
|
||||
})();
|
||||
|
After Width: | Height: | Size: 183 KiB |
@@ -0,0 +1,106 @@
|
||||
/* Fleet · Space — the glass cards, set free on an open pan/zoom field.
|
||||
Loads after fleet-glass.css and overrides only the layout layer. */
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 52px 0 26px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
.stage.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.world {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Stat band floats over the field. */
|
||||
.stats {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
padding-bottom: 14px;
|
||||
background: linear-gradient(180deg, rgb(10 10 10 / 0.92) 40%, transparent);
|
||||
}
|
||||
|
||||
/* Project labels: light in the dark, not containers. */
|
||||
.region {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.region .r-name {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}
|
||||
.region .r-meta {
|
||||
margin-left: 10px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--muted-foreground) 75%, transparent);
|
||||
}
|
||||
|
||||
/* Cards become free-floating world objects. */
|
||||
.world .wt {
|
||||
position: absolute;
|
||||
width: 252px;
|
||||
cursor: grab;
|
||||
}
|
||||
.world .wt:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.world .wt.is-dragging {
|
||||
transition: none;
|
||||
z-index: 6;
|
||||
}
|
||||
.world .wt:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Keep orbs clickable while the card itself drags. */
|
||||
.world .wt .orb,
|
||||
.world .wt .ask {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewtools {
|
||||
position: fixed;
|
||||
left: 20px;
|
||||
bottom: 38px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.vt {
|
||||
padding: 5px 11px;
|
||||
border: 1px solid rgb(255 255 255 / 0.09);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
color: var(--muted-foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
.vt:hover {
|
||||
background: rgb(255 255 255 / 0.09);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.vt:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Footer pinned (the stage is absolute, so flex flow would float this). */
|
||||
.whisper {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fleet — Space</title>
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-glass.css" />
|
||||
<link rel="stylesheet" href="./fleet-space.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<span class="wordmark">Fleet</span>
|
||||
<nav class="tabs">
|
||||
<a href="./fleet-glass.html">Glass</a>
|
||||
<a href="./fleet-board.html">Board</a>
|
||||
<a href="./fleet-triage.html">Triage</a>
|
||||
<span class="is-active">Space</span>
|
||||
</nav>
|
||||
<input class="search" id="search" type="text" />
|
||||
<span class="accounting" id="accounting"></span>
|
||||
</header>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<div class="stage" id="stage">
|
||||
<div class="world" id="world"></div>
|
||||
</div>
|
||||
|
||||
<div class="viewtools">
|
||||
<button class="vt" id="fit">Fit</button>
|
||||
<button class="vt" id="reset" disabled>Reset layout</button>
|
||||
</div>
|
||||
|
||||
<aside class="panel" id="panel" hidden></aside>
|
||||
<div class="tooltip" id="tooltip" hidden></div>
|
||||
|
||||
<footer class="whisper" id="whisper"></footer>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-space.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,385 @@
|
||||
/* Fleet · Space — glass worktree cards on an open pan/zoom field. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const F = window.FLEET;
|
||||
const MAC = navigator.userAgent.includes('Mac');
|
||||
const LAYOUT_KEY = 'fleet-space-layout-v1';
|
||||
const COL_W = 252;
|
||||
const GAP = 18;
|
||||
const COLS = { automation: 2, orca: 4, 'orca-mobile': 3 };
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
const STATE_LABEL = {
|
||||
attention: 'Needs an answer', permission: 'Awaiting permission', failed: 'Failed',
|
||||
working: 'Working', review: 'Ready to review', done: 'Done',
|
||||
};
|
||||
|
||||
// ---- chrome (same as glass) ----------------------------------------------
|
||||
document.getElementById('accounting').textContent =
|
||||
F.counts.total + ' / ' + F.counts.fleetTotal + ' agents · ' +
|
||||
F.byWorktree.length + ' / ' + F.counts.fleetWorktrees + ' worktrees';
|
||||
document.getElementById('search').placeholder = 'Filter… ' + (MAC ? '⌘' : 'Ctrl+') + 'K';
|
||||
const stats = document.getElementById('stats');
|
||||
[
|
||||
['is-attention', F.counts.needsYou, 'need you'],
|
||||
['is-working', F.counts.working, 'working'],
|
||||
['is-review', F.counts.review, 'to review'],
|
||||
['is-done', F.counts.done, 'done'],
|
||||
].forEach(([cls, n, label]) => {
|
||||
const s = el('div', 'stat ' + cls);
|
||||
s.append(el('span', 'n', String(n)), el('span', 'l', label));
|
||||
stats.append(s);
|
||||
});
|
||||
|
||||
// ---- world ----------------------------------------------------------------
|
||||
const stage = document.getElementById('stage');
|
||||
const world = document.getElementById('world');
|
||||
const orbEls = new Map();
|
||||
const cards = [];
|
||||
let selected = null;
|
||||
|
||||
function deltas() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LAYOUT_KEY) || '{}') || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function saveDelta(key, dx, dy) {
|
||||
const d = deltas();
|
||||
d[key] = { dx, dy };
|
||||
localStorage.setItem(LAYOUT_KEY, JSON.stringify(d));
|
||||
resetBtn.disabled = false;
|
||||
}
|
||||
|
||||
function tailBlock(a, count) {
|
||||
const pre = el('pre', 'tail');
|
||||
const lines = F.fakeTail(a).slice(-count);
|
||||
lines.forEach((ln, i) => pre.append(el('span', i === lines.length - 1 ? 'now' : null, ln + '\n')));
|
||||
return pre;
|
||||
}
|
||||
|
||||
F.projects.forEach((p) => {
|
||||
const region = el('div', 'region');
|
||||
region.append(
|
||||
el('span', 'r-name', p.name),
|
||||
el('span', 'r-meta', p.worktrees.length + ' worktrees · ' + p.agents.length + ' agents'),
|
||||
);
|
||||
region.dataset.project = p.name;
|
||||
world.append(region);
|
||||
|
||||
const wts = p.worktrees.slice().sort((a, b) => {
|
||||
const na = F.severityRank(a.worstState) <= 2 ? 0 : 1;
|
||||
const nb = F.severityRank(b.worstState) <= 2 ? 0 : 1;
|
||||
return na - nb || a.worktree.localeCompare(b.worktree);
|
||||
});
|
||||
|
||||
wts.forEach((g) => {
|
||||
const card = el('article', 'wt');
|
||||
card.dataset.worst = g.worstState;
|
||||
card.dataset.project = p.name;
|
||||
card.dataset.key = p.name + '/' + g.worktree;
|
||||
card.dataset.search = (g.worktree + ' ' + g.agents.map((a) => a.name).join(' ')).toLowerCase();
|
||||
|
||||
const head = el('div', 'wt-head');
|
||||
head.append(el('span', 'wt-name', g.worktree), el('span', 'wt-count', String(g.agents.length)));
|
||||
card.append(head);
|
||||
|
||||
const orbs = el('div', 'orbs');
|
||||
const loud = [];
|
||||
g.agents.forEach((a) => {
|
||||
const o = el('i', 'orb');
|
||||
o.dataset.state = a.state;
|
||||
o.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
select(a, o);
|
||||
});
|
||||
o.addEventListener('mousemove', (e) => showTip(e, a));
|
||||
o.addEventListener('mouseleave', hideTip);
|
||||
orbs.append(o);
|
||||
orbEls.set(a.id, o);
|
||||
if (a.bucket === 'needsYou') loud.push(a);
|
||||
});
|
||||
card.append(orbs);
|
||||
|
||||
loud.forEach((a) => {
|
||||
const ask = el('div', 'ask');
|
||||
const o = el('i', 'orb');
|
||||
o.dataset.state = a.state;
|
||||
o.style.animation = 'none';
|
||||
const text =
|
||||
a.state === 'attention' ? a.question.text :
|
||||
a.state === 'permission' ? 'Allow ' + (F.decisions.find((d) => d.key === a.decisionKey) || {}).command + '?' :
|
||||
a.failure;
|
||||
ask.append(o, el('span', null, text));
|
||||
ask.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
select(a, orbEls.get(a.id));
|
||||
});
|
||||
card.append(ask);
|
||||
});
|
||||
|
||||
world.append(card);
|
||||
cards.push({ card, project: p.name, key: card.dataset.key });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- masonry layout (two-pass: measure real heights, then place) ----------
|
||||
const basePos = new Map();
|
||||
function layout() {
|
||||
const anchors = {};
|
||||
let x = 0;
|
||||
F.projects.forEach((p) => {
|
||||
const cols = COLS[p.name] || 3;
|
||||
anchors[p.name] = { x, cols };
|
||||
x += cols * (COL_W + GAP) + 110;
|
||||
});
|
||||
const colHeights = {};
|
||||
F.projects.forEach((p) => {
|
||||
colHeights[p.name] = new Array(anchors[p.name].cols).fill(64);
|
||||
});
|
||||
const saved = deltas();
|
||||
cards.forEach(({ card, project, key }) => {
|
||||
const a = anchors[project];
|
||||
const h = card.offsetHeight;
|
||||
const heights = colHeights[project];
|
||||
let col = 0;
|
||||
for (let i = 1; i < heights.length; i++) if (heights[i] < heights[col]) col = i;
|
||||
const jx = (F.hash(key + ':x') % 15) - 7;
|
||||
const jy = (F.hash(key + ':y') % 17) - 8;
|
||||
const bx = a.x + col * (COL_W + GAP) + jx;
|
||||
const by = heights[col] + jy;
|
||||
basePos.set(key, { x: bx, y: by });
|
||||
const d = saved[key] || { dx: 0, dy: 0 };
|
||||
card.style.left = bx + d.dx + 'px';
|
||||
card.style.top = by + d.dy + 'px';
|
||||
heights[col] += h + GAP;
|
||||
});
|
||||
document.querySelectorAll('.region').forEach((r) => {
|
||||
const a = anchors[r.dataset.project];
|
||||
r.style.left = a.x + 2 + 'px';
|
||||
r.style.top = '10px';
|
||||
});
|
||||
if (Object.keys(saved).length) resetBtn.disabled = false;
|
||||
}
|
||||
|
||||
// ---- pan / zoom -----------------------------------------------------------
|
||||
const view = { x: 0, y: 0, k: 1 };
|
||||
function apply() {
|
||||
world.style.transform = 'translate(' + view.x + 'px,' + view.y + 'px) scale(' + view.k + ')';
|
||||
}
|
||||
function bounds() {
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
cards.forEach(({ card }) => {
|
||||
maxX = Math.max(maxX, card.offsetLeft + card.offsetWidth);
|
||||
maxY = Math.max(maxY, card.offsetTop + card.offsetHeight);
|
||||
});
|
||||
return { w: maxX + 40, h: maxY + 40 };
|
||||
}
|
||||
function fit() {
|
||||
const b = bounds();
|
||||
const r = stage.getBoundingClientRect();
|
||||
view.k = Math.max(0.32, Math.min(1, Math.min(r.width / b.w, r.height / b.h)));
|
||||
view.x = (r.width - b.w * view.k) / 2;
|
||||
view.y = 74;
|
||||
apply();
|
||||
}
|
||||
|
||||
stage.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const r = stage.getBoundingClientRect();
|
||||
const mx = e.clientX - r.left;
|
||||
const my = e.clientY - r.top;
|
||||
const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.012 : 0.0016));
|
||||
const k = Math.min(1.6, Math.max(0.32, view.k * factor));
|
||||
view.x = mx - ((mx - view.x) / view.k) * k;
|
||||
view.y = my - ((my - view.y) / view.k) * k;
|
||||
view.k = k;
|
||||
apply();
|
||||
}, { passive: false });
|
||||
|
||||
let drag = null;
|
||||
stage.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.panel, .viewtools, .orb, .ask')) return;
|
||||
const cardEl = e.target.closest('.wt');
|
||||
drag = {
|
||||
cardEl,
|
||||
key: cardEl?.dataset.key,
|
||||
sx: e.clientX,
|
||||
sy: e.clientY,
|
||||
ox: cardEl ? cardEl.offsetLeft : view.x,
|
||||
oy: cardEl ? cardEl.offsetTop : view.y,
|
||||
moved: false,
|
||||
};
|
||||
if (!cardEl) stage.classList.add('is-panning');
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!drag) return;
|
||||
const dx = e.clientX - drag.sx;
|
||||
const dy = e.clientY - drag.sy;
|
||||
if (!drag.moved && Math.hypot(dx, dy) < 3) return;
|
||||
drag.moved = true;
|
||||
if (drag.cardEl) {
|
||||
drag.cardEl.classList.add('is-dragging');
|
||||
drag.cardEl.style.left = drag.ox + dx / view.k + 'px';
|
||||
drag.cardEl.style.top = drag.oy + dy / view.k + 'px';
|
||||
} else {
|
||||
view.x = drag.ox + dx;
|
||||
view.y = drag.oy + dy;
|
||||
apply();
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (drag?.cardEl && drag.moved) {
|
||||
const base = basePos.get(drag.key);
|
||||
saveDelta(drag.key, drag.cardEl.offsetLeft - base.x, drag.cardEl.offsetTop - base.y);
|
||||
drag.cardEl.classList.remove('is-dragging');
|
||||
}
|
||||
stage.classList.remove('is-panning');
|
||||
drag = null;
|
||||
});
|
||||
stage.addEventListener('dblclick', (e) => {
|
||||
if (!e.target.closest('.wt')) fit();
|
||||
});
|
||||
|
||||
const resetBtn = document.getElementById('reset');
|
||||
document.getElementById('fit').addEventListener('click', fit);
|
||||
resetBtn.addEventListener('click', () => {
|
||||
localStorage.removeItem(LAYOUT_KEY);
|
||||
resetBtn.disabled = true;
|
||||
cards.forEach(({ card, key }) => {
|
||||
const b = basePos.get(key);
|
||||
card.style.left = b.x + 'px';
|
||||
card.style.top = b.y + 'px';
|
||||
});
|
||||
});
|
||||
|
||||
// ---- tooltip / panel (same behavior as glass) -----------------------------
|
||||
const tip = document.getElementById('tooltip');
|
||||
function showTip(e, a) {
|
||||
tip.hidden = false;
|
||||
tip.replaceChildren(
|
||||
el('div', 't-name', a.shortName),
|
||||
el('div', 't-meta', STATE_LABEL[a.state] + ' · ' + a.agentType + ' · ' +
|
||||
F.ageLabel(a.state === 'done' || a.state === 'review' ? a.durationMs : a.startedAgo) +
|
||||
(a.detail && a.state === 'working' ? ' · ' + a.detail : '')),
|
||||
);
|
||||
const w = tip.offsetWidth;
|
||||
const h = tip.offsetHeight;
|
||||
let x = e.clientX + 14;
|
||||
let y = e.clientY - h - 10;
|
||||
if (x + w > innerWidth - 8) x = e.clientX - w - 14;
|
||||
if (y < 60) y = e.clientY + 14;
|
||||
tip.style.left = x + 'px';
|
||||
tip.style.top = y + 'px';
|
||||
}
|
||||
function hideTip() {
|
||||
tip.hidden = true;
|
||||
}
|
||||
|
||||
const panel = document.getElementById('panel');
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
if (selected) orbEls.get(selected.id)?.classList.remove('is-selected');
|
||||
selected = null;
|
||||
}
|
||||
function select(a, orb) {
|
||||
if (selected) orbEls.get(selected.id)?.classList.remove('is-selected');
|
||||
selected = a;
|
||||
orb?.classList.add('is-selected');
|
||||
panel.hidden = false;
|
||||
panel.replaceChildren();
|
||||
|
||||
const close = el('button', 'close', '✕');
|
||||
close.addEventListener('click', closePanel);
|
||||
panel.append(close, el('h1', null, a.shortName));
|
||||
|
||||
const sl = el('div', 'state-line');
|
||||
const d = el('i', 'orb');
|
||||
d.dataset.state = a.state;
|
||||
d.style.animation = 'none';
|
||||
sl.append(d, el('span', null, STATE_LABEL[a.state] + ' · ' + (a.alive ? 'process live' : 'reply restarts it')));
|
||||
panel.append(sl, el('p', 'path', a.displayProject + ' / ' + a.worktree));
|
||||
panel.append(tailBlock(a, 8));
|
||||
|
||||
if (a.state === 'attention') {
|
||||
panel.append(el('p', 'question', a.question.text));
|
||||
const opts = el('div', 'opts');
|
||||
a.question.options.forEach((opt, i) => {
|
||||
const b = el('button');
|
||||
b.append(el('kbd', null, String(i + 1)), document.createTextNode(opt));
|
||||
opts.append(b);
|
||||
});
|
||||
panel.append(opts);
|
||||
} else if (a.state === 'permission') {
|
||||
const dec = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
panel.append(el('p', 'question', 'Allow ' + dec.command + '? ' + dec.agents.length + ' agents are waiting on this one decision.'));
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Allow — resumes ' + dec.agents.length), el('button', null, 'Deny'));
|
||||
panel.append(actions);
|
||||
} else if (a.state === 'failed') {
|
||||
panel.append(el('p', 'question', a.failure));
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Retry'), el('button', null, 'Open terminal'));
|
||||
panel.append(actions);
|
||||
} else if (a.state === 'review') {
|
||||
const actions = el('div', 'actions');
|
||||
actions.append(el('button', 'primary', 'Review diff'), el('button', null, 'Mark reviewed'));
|
||||
panel.append(actions);
|
||||
}
|
||||
const reply = el('div', 'reply');
|
||||
reply.append(Object.assign(el('input'), {
|
||||
placeholder: a.state === 'working' ? 'Queue a message…' : 'Reply…',
|
||||
}));
|
||||
panel.append(reply);
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closePanel();
|
||||
});
|
||||
|
||||
// ---- search ---------------------------------------------------------------
|
||||
const search = document.getElementById('search');
|
||||
search.addEventListener('input', () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
cards.forEach(({ card }) => {
|
||||
card.classList.toggle('is-dim', !!q && !card.dataset.search.includes(q));
|
||||
});
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((MAC ? e.metaKey : e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
search.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- whisper --------------------------------------------------------------
|
||||
const whisper = document.getElementById('whisper');
|
||||
[
|
||||
['var(--state-attention)', 'needs you'],
|
||||
['color-mix(in srgb, var(--state-working) 62%, transparent)', 'working'],
|
||||
['color-mix(in srgb, var(--state-done) 72%, transparent)', 'review'],
|
||||
['rgb(255 255 255 / 0.14)', 'done'],
|
||||
].forEach(([color, label]) => {
|
||||
const s = el('span');
|
||||
const i = el('i');
|
||||
i.style.background = color;
|
||||
s.append(i, el('span', null, label));
|
||||
whisper.append(s);
|
||||
});
|
||||
whisper.append(el('span', null, 'drag cards to arrange · scroll to zoom · captured ' +
|
||||
new Date(F.now).toISOString().slice(11, 16) + ' UTC'));
|
||||
|
||||
// ---- boot: measure, place, fit, seed --------------------------------------
|
||||
requestAnimationFrame(() => {
|
||||
layout();
|
||||
fit();
|
||||
const seed = F.projects.flatMap((p) => p.agents).find((a) => a.state === 'attention');
|
||||
if (seed) select(seed, orbEls.get(seed.id));
|
||||
});
|
||||
})();
|
||||
|
After Width: | Height: | Size: 240 KiB |
@@ -0,0 +1,202 @@
|
||||
/* Enriches the real captured fleet snapshot (window.AGENT_ACTIVITY_SNAPSHOT, 104 agents)
|
||||
with the states the capture lacks: the CLI only reports working|done, so attention /
|
||||
permission / failed / review, process liveness, PRs and pins are synthesized
|
||||
DETERMINISTICALLY (FNV-1a over agent id) so every reload and screenshot is identical.
|
||||
Exposes window.FLEET. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const snapshot = window.AGENT_ACTIVITY_SNAPSHOT;
|
||||
if (!snapshot) throw new Error('actual-agent-activity.js must be loaded first');
|
||||
const NOW = snapshot.capturedAt;
|
||||
|
||||
function hash(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
const QUESTIONS = [
|
||||
{
|
||||
text: 'pnpm test fails on main too — proceed with the fix anyway, or investigate the baseline failure first?',
|
||||
options: ['Proceed, note baseline failure in the PR', 'Investigate the baseline first', 'Skip the failing suite'],
|
||||
},
|
||||
{
|
||||
text: 'This touches both the renderer IPC contract and the pop-out payload. Keep one PR or split?',
|
||||
options: ['Keep one PR', 'Split: IPC contract first', 'Show me the diff before deciding'],
|
||||
},
|
||||
{
|
||||
text: 'The worktree has uncommitted changes from a previous run. Stash them or start clean?',
|
||||
options: ['Stash and continue', 'Discard and start clean', 'Show me the diff'],
|
||||
},
|
||||
];
|
||||
|
||||
const DECISIONS = [
|
||||
{
|
||||
key: 'bash:pnpm-test',
|
||||
title: 'Allow `pnpm test`',
|
||||
command: 'pnpm test --filter dashboard',
|
||||
detail: 'Run the workspace test suite',
|
||||
},
|
||||
{
|
||||
key: 'bash:gh-pr-create',
|
||||
title: 'Allow `gh pr create`',
|
||||
command: 'gh pr create --fill',
|
||||
detail: 'Open a pull request with the branch changes',
|
||||
},
|
||||
];
|
||||
|
||||
const FAILURES = [
|
||||
'Error: vitest worker terminated (OOM) — 3 retries exhausted',
|
||||
'fatal: could not read from remote repository — auth prompt in non-interactive shell',
|
||||
];
|
||||
|
||||
// ---- state assignment ------------------------------------------------------
|
||||
const working = snapshot.agents.filter((a) => a.status === 'working');
|
||||
const done = snapshot.agents.filter((a) => a.status !== 'working');
|
||||
working.sort((a, b) => hash(a.id) - hash(b.id));
|
||||
done.sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0));
|
||||
|
||||
const stateById = new Map();
|
||||
working.forEach((a, i) => {
|
||||
if (i < 3) stateById.set(a.id, 'attention');
|
||||
else if (i < 9) stateById.set(a.id, 'permission');
|
||||
else if (i < 11) stateById.set(a.id, 'failed');
|
||||
else stateById.set(a.id, 'working');
|
||||
});
|
||||
done.forEach((a, i) => stateById.set(a.id, i < 14 ? 'review' : 'done'));
|
||||
|
||||
const BUCKET = {
|
||||
attention: 'needsYou', permission: 'needsYou', failed: 'needsYou',
|
||||
working: 'working', review: 'review', done: 'done',
|
||||
};
|
||||
const STATE_COLOR = {
|
||||
attention: '--state-attention', permission: '--state-attention', failed: '--state-blocked',
|
||||
working: '--state-working', review: '--state-done', done: '--state-idle',
|
||||
};
|
||||
// Shape carries state independently of hue (yellow≈amber is CVD-unsafe by itself).
|
||||
const STATE_GLYPH = { attention: '?', permission: '⇧', failed: '✕', working: '✻', review: '✔', done: '∙' };
|
||||
|
||||
let qi = 0;
|
||||
let di = 0;
|
||||
let prNumber = 11731;
|
||||
const agents = snapshot.agents.map((raw) => {
|
||||
const state = stateById.get(raw.id);
|
||||
const h = hash(raw.id);
|
||||
const a = Object.assign({}, raw);
|
||||
a.state = state;
|
||||
a.bucket = BUCKET[state];
|
||||
a.displayProject =
|
||||
/mobile/.test(raw.worktree) ? 'orca-mobile'
|
||||
: /^auto-daily/.test(raw.branchFrom || '') ? 'automation'
|
||||
: 'orca';
|
||||
a.shortName = raw.name.length > 64 ? raw.name.slice(0, 63).trimEnd() + '…' : raw.name;
|
||||
a.question = state === 'attention' ? QUESTIONS[qi++ % QUESTIONS.length] : null;
|
||||
a.decisionKey = state === 'permission' ? DECISIONS[di++ < 4 ? 0 : 1].key : null;
|
||||
a.failure = state === 'failed' ? FAILURES[h % FAILURES.length] : null;
|
||||
// Liveness is a separate channel from state: alive replies now, exited restarts on reply.
|
||||
a.alive = a.bucket === 'needsYou' || state === 'working' ? h % 11 !== 0 : state === 'review' && h % 4 === 0;
|
||||
a.loopSleeping = state === 'working' && h % 17 === 0;
|
||||
a.pr = (state === 'review' || state === 'done') && h % 5 === 0 ? { number: prNumber++, state: 'open' } : null;
|
||||
a.pinned = false;
|
||||
a.startedAgo = NOW - raw.startedAt;
|
||||
a.durationMs = (raw.completedAt || NOW) - raw.startedAt;
|
||||
return a;
|
||||
});
|
||||
// Two deterministic pins: the longest-running attention agent, the newest review agent.
|
||||
const att = agents.filter((a) => a.state === 'attention').sort((a, b) => b.startedAgo - a.startedAgo);
|
||||
if (att[0]) att[0].pinned = true;
|
||||
const rev = agents.filter((a) => a.state === 'review');
|
||||
if (rev[0]) rev[0].pinned = true;
|
||||
|
||||
// ---- grouping (stable: alpha by name, agents by start time — never a live metric) ----
|
||||
const SEVERITY = ['failed', 'attention', 'permission', 'working', 'review', 'done'];
|
||||
const worst = (list) => SEVERITY[Math.min(...list.map((a) => SEVERITY.indexOf(a.state)))];
|
||||
|
||||
const byWorktree = [];
|
||||
const wtIndex = new Map();
|
||||
for (const a of agents) {
|
||||
const key = a.displayProject + '/' + a.worktree;
|
||||
if (!wtIndex.has(key)) {
|
||||
wtIndex.set(key, { project: a.displayProject, worktree: a.worktree, unread: false, agents: [] });
|
||||
byWorktree.push(wtIndex.get(key));
|
||||
}
|
||||
const g = wtIndex.get(key);
|
||||
g.agents.push(a);
|
||||
g.unread = g.unread || a.unread;
|
||||
}
|
||||
byWorktree.sort((x, y) => x.worktree.localeCompare(y.worktree));
|
||||
byWorktree.forEach((g) => {
|
||||
g.agents.sort((x, y) => x.startedAt - y.startedAt);
|
||||
g.worstState = worst(g.agents);
|
||||
});
|
||||
|
||||
const projects = [];
|
||||
const pIndex = new Map();
|
||||
for (const g of byWorktree) {
|
||||
if (!pIndex.has(g.project)) {
|
||||
pIndex.set(g.project, { name: g.project, worktrees: [], agents: [] });
|
||||
projects.push(pIndex.get(g.project));
|
||||
}
|
||||
pIndex.get(g.project).worktrees.push(g);
|
||||
pIndex.get(g.project).agents.push(...g.agents);
|
||||
}
|
||||
projects.sort((x, y) => x.name.localeCompare(y.name));
|
||||
projects.forEach((p) => { p.worstState = worst(p.agents); });
|
||||
|
||||
const decisions = DECISIONS.map((d) => Object.assign({}, d, {
|
||||
agents: agents.filter((a) => a.decisionKey === d.key),
|
||||
})).filter((d) => d.agents.length > 0);
|
||||
|
||||
const buckets = { needsYou: [], working: [], review: [], done: [] };
|
||||
agents.forEach((a) => buckets[a.bucket].push(a));
|
||||
const counts = {
|
||||
needsYou: buckets.needsYou.length,
|
||||
working: buckets.working.length,
|
||||
review: buckets.review.length,
|
||||
done: buckets.done.length,
|
||||
total: agents.length,
|
||||
fleetTotal: snapshot.sourceSummary.agents,
|
||||
fleetWorktrees: snapshot.sourceSummary.worktrees,
|
||||
};
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
function ageLabel(ms) {
|
||||
const m = Math.round(ms / 60000);
|
||||
if (m < 1) return 'now';
|
||||
if (m < 60) return m + 'm';
|
||||
const hrs = Math.floor(m / 60);
|
||||
if (hrs < 24) return hrs + 'h ' + (m % 60) + 'm';
|
||||
return Math.floor(hrs / 24) + 'd ' + (hrs % 24) + 'h';
|
||||
}
|
||||
|
||||
const TAIL_WORKING = [
|
||||
['$ pnpm vitest run src/renderer/src/components/dashboard-popout', '', ' RUN AgentKanbanBoard.test.tsx', ' ✓ groups cards by bucket (18ms)', ' ✓ keeps column order stable across refresh (9ms)'],
|
||||
['$ git diff --stat', ' src/main/ipc/dashboard-payload-validation.ts | 41 ++++--', ' 3 files changed, 96 insertions(+), 22 deletions(-)', '$ pnpm oxlint src/main/ipc'],
|
||||
['Reading src/shared/dashboard-snapshot.ts…', 'Editing build-dashboard-snapshot.ts', ' + carried stateChangedAt through the retained-row path'],
|
||||
];
|
||||
const TAIL_DONE = [
|
||||
['✔ All 42 tests passed', '✔ Finished — 3 files changed, +214 −87', 'Ready for review.'],
|
||||
['$ gh pr view --json state', '{ "state": "OPEN" }', '✔ Finished — PR updated, CI running.'],
|
||||
];
|
||||
function fakeTail(a) {
|
||||
const h = hash(a.id + ':tail');
|
||||
let lines;
|
||||
if (a.state === 'failed') lines = ['$ ' + (DECISIONS[0].command), '', a.failure, 'Process exited with code 1'];
|
||||
else if (a.state === 'attention') lines = TAIL_WORKING[h % TAIL_WORKING.length].concat(['', '? ' + a.question.text]);
|
||||
else if (a.state === 'permission') lines = ['About to run:', ' $ ' + DECISIONS.find((d) => d.key === a.decisionKey).command, '', 'Waiting for permission…'];
|
||||
else if (a.bucket === 'working') lines = TAIL_WORKING[h % TAIL_WORKING.length].concat([a.detail ? '· ' + a.detail : '']);
|
||||
else lines = TAIL_DONE[h % TAIL_DONE.length];
|
||||
return lines;
|
||||
}
|
||||
|
||||
window.FLEET = {
|
||||
now: NOW, agents, byWorktree, projects, decisions, buckets, counts,
|
||||
ageLabel, fakeTail, hash,
|
||||
stateColorVar: (s) => STATE_COLOR[s],
|
||||
stateGlyph: (s) => STATE_GLYPH[s],
|
||||
severityRank: (s) => SEVERITY.indexOf(s),
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,776 @@
|
||||
/* Fleet · Strips — ATC flight-strip bay wall.
|
||||
Lanes are states, rows are strips, nothing leaves a bay until it is acted on.
|
||||
Chrome is monochrome; only --state-* tokens carry hue, and hue never appears
|
||||
without a glyph shape carrying the same meaning. */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
min-width: 1280px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.shell {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 14px 0;
|
||||
}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
|
||||
.topbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
height: 54px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.topbar-left,
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.tab {
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.tab.is-active {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.accounting {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.segmented {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.seg {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.seg.is-active {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Idle-worker control: count and cycle affordance in one pill. Self-clearing —
|
||||
it counts down as decisions are answered and goes quiet at zero. */
|
||||
.needs-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
padding: 0 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--state-attention);
|
||||
background: color-mix(in srgb, var(--state-attention) 9%, transparent);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.needs-pill .pill-glyph {
|
||||
color: var(--state-attention);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.needs-pill .pill-sep {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.needs-pill.is-clear {
|
||||
border-color: var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.needs-pill.is-clear .pill-glyph {
|
||||
color: var(--state-done);
|
||||
}
|
||||
|
||||
/* ---------- bay stack ---------- */
|
||||
|
||||
.bays {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 6px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 82px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* NEEDS YOU takes every leftover pixel — its cards, failures, and peek panel are
|
||||
the screen's whole point. Quiet bays hold fixed one/two-strip racks (headers
|
||||
carry the counts) and scroll internally; DONE keeps its fold row pinned. */
|
||||
.bay-needsYou {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.bay-working,
|
||||
.bay-review,
|
||||
.bay-done {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.bay-working .bay-body { height: 39px; }
|
||||
.bay-review .bay-body { height: 39px; }
|
||||
.bay-done .bay-body { height: 30px; }
|
||||
/* Overflow fades instead of slicing a strip mid-row. */
|
||||
.bay-needsYou .bay-body {
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);
|
||||
}
|
||||
|
||||
.bay-head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.bay-name {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.bay-needsYou .bay-name {
|
||||
color: var(--state-attention);
|
||||
}
|
||||
.count-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
.bay-affordance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bay-affordance.is-latched {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 1px 9px 2px;
|
||||
}
|
||||
.bay-note {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.bay-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--accent) transparent;
|
||||
}
|
||||
.bay-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.bay-body::-webkit-scrollbar-thumb {
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
border: 2px solid var(--card);
|
||||
}
|
||||
|
||||
/* ---------- strip ---------- */
|
||||
|
||||
.strip-group {
|
||||
position: relative;
|
||||
}
|
||||
.strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 11px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
.strip:hover,
|
||||
.strip-group.is-hovered > .strip {
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
}
|
||||
/* Focus rides on an inset ring so it survives alongside the blink overlay. */
|
||||
.strip-group.is-focused > .strip {
|
||||
background: rgb(255 255 255 / 0.04);
|
||||
box-shadow: inset 0 0 0 1px var(--ring);
|
||||
}
|
||||
|
||||
/* Motion is a two-value grammar: fast border-blink = unacknowledged needs-you,
|
||||
slow pulse = finished but unreviewed. Everything else is still. */
|
||||
.strip-group.is-blinking > .strip::after,
|
||||
.strip-group.is-pulsing > .strip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
.strip-group.is-blinking > .strip::after {
|
||||
animation: blink-border-attention var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
.strip-group.is-blinking.state-failed > .strip::after {
|
||||
animation-name: blink-border-blocked;
|
||||
}
|
||||
.strip-group.is-pulsing > .strip::after {
|
||||
animation: pulse-review var(--pulse-slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Pinned strips ride cocked out of the rack, ATC-style, and sort to the top. */
|
||||
.strip-group.is-pinned > .strip {
|
||||
transform: translateX(10px) rotate(-0.35deg);
|
||||
background: color-mix(in srgb, var(--secondary) 45%, var(--card));
|
||||
box-shadow: var(--shadow-lift);
|
||||
z-index: 1;
|
||||
}
|
||||
.strip-group.is-pinned > .strip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--strip-state);
|
||||
}
|
||||
.strip-group.is-pinned.is-focused > .strip {
|
||||
box-shadow: inset 0 0 0 1px var(--ring), var(--shadow-lift);
|
||||
}
|
||||
.strip-group.is-pinned > .peek {
|
||||
margin-left: 52px;
|
||||
}
|
||||
|
||||
/* strip cells */
|
||||
.cell-name {
|
||||
flex: 0 1 250px;
|
||||
min-width: 90px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-age {
|
||||
flex: 0 0 74px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip-type {
|
||||
flex: 0 0 auto;
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.chip-worktree {
|
||||
flex: 0 0 auto;
|
||||
max-width: 195px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-pr {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.chip-note {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
border: 1px dashed var(--input);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip-tag {
|
||||
flex: 0 0 auto;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.pin {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--state-idle);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pin:hover {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.pin.is-on {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ---------- glyphs: shape carries state, fill carries liveness ---------- */
|
||||
|
||||
.glyph {
|
||||
flex: 0 0 auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--strip-state);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
.glyph.g-char {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Exited process: hollow and dim. A reply restarts it. */
|
||||
.glyph.is-exited {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.glyph.is-exited.g-char {
|
||||
font-weight: 400;
|
||||
}
|
||||
.glyph .ring {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
animation: spin 3.4s linear infinite;
|
||||
}
|
||||
.glyph.is-exited .ring {
|
||||
animation: none;
|
||||
border-color: currentColor;
|
||||
border-style: dotted;
|
||||
}
|
||||
/* Sleeping loop: a crescent, not a ring — it is not holding a slot. */
|
||||
.glyph .crescent {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset -3.5px 0 0 0 currentColor;
|
||||
}
|
||||
/* Working renders dim so amber and red keep the top of the salience ladder. */
|
||||
.glyph.state-working {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.glyph.state-working.is-exited {
|
||||
opacity: 0.36;
|
||||
}
|
||||
.glyph.state-done {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.strip-group.state-attention,
|
||||
.decision-card {
|
||||
--strip-state: var(--state-attention);
|
||||
}
|
||||
.strip-group.state-permission {
|
||||
--strip-state: var(--state-attention);
|
||||
}
|
||||
.strip-group.state-failed {
|
||||
--strip-state: var(--state-blocked);
|
||||
}
|
||||
.strip-group.state-working {
|
||||
--strip-state: var(--state-working);
|
||||
}
|
||||
.strip-group.state-review {
|
||||
--strip-state: var(--state-done);
|
||||
}
|
||||
.strip-group.state-done {
|
||||
--strip-state: var(--state-idle);
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.btn:hover {
|
||||
color: var(--foreground);
|
||||
border-color: var(--input);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--foreground);
|
||||
border-color: var(--foreground);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* ---------- coalesced decision card ---------- */
|
||||
|
||||
.decision-card {
|
||||
margin: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--secondary) 38%, var(--card));
|
||||
padding: 6px 11px 7px;
|
||||
}
|
||||
.decision-card.is-collapsed {
|
||||
padding: 6px 11px;
|
||||
}
|
||||
.decision-card.is-live {
|
||||
animation: blink-border-attention var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
.dc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dc-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-title code,
|
||||
.dc-cmd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
background: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.dc-detail {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-meta {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.dc-controls .eyebrow {
|
||||
margin-right: -2px;
|
||||
}
|
||||
/* Collapsed card: the whole decision — scope, Allow, Deny — folds into one row. */
|
||||
.dc-controls.dc-inline {
|
||||
margin-top: 0;
|
||||
}
|
||||
.dc-agents {
|
||||
margin-top: 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.dc-agent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
height: 19px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dc-agent .glyph {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.dc-agent .dc-agent-name {
|
||||
flex: 0 1 230px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-agent .cell-age {
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
.dc-expand {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dc-expand:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.dc-resolved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.dc-resolved .glyph {
|
||||
color: var(--state-done);
|
||||
}
|
||||
|
||||
/* ---------- peek: expanded in place, pushes the rack down ---------- */
|
||||
|
||||
.peek {
|
||||
margin: 0 12px 6px 42px;
|
||||
padding: 7px 12px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--secondary) 40%, var(--card));
|
||||
}
|
||||
.peek-question {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
/* Answer options and the free-text reply share one row: respond in place without
|
||||
the panel pushing the rack below the fold. */
|
||||
.peek-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.peek-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.peek-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--input);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.peek-input {
|
||||
flex: 1 1 220px;
|
||||
min-width: 160px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.peek-input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.peek-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
}
|
||||
.peek-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ---------- done fold ---------- */
|
||||
|
||||
.fold-row {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
background: color-mix(in srgb, var(--secondary) 30%, var(--card));
|
||||
border-top: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fold-row:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.fold-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fold-rule {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.legend-keys {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.legend-keys .sep {
|
||||
opacity: 0.5;
|
||||
padding: 0 3px;
|
||||
}
|
||||
.legend-motion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.legend-dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.legend-sample {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.sample-blink {
|
||||
animation: blink-border-attention var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
.sample-pulse {
|
||||
animation: pulse-review var(--pulse-slow) ease-in-out infinite;
|
||||
}
|
||||
.sample-still {
|
||||
border-color: var(--state-idle);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fleet · Strips</title>
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-strips.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<h1 class="title">Fleet</h1>
|
||||
<nav class="tabs" aria-label="Fleet views">
|
||||
<a class="tab" href="./fleet-map.html">Map</a>
|
||||
<span class="tab is-active" aria-current="page">Strips</span>
|
||||
<a class="tab" href="./mission-control.html">Mission Control</a>
|
||||
</nav>
|
||||
<p class="accounting" id="accounting"></p>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<div class="group-toggle">
|
||||
<span class="eyebrow">Group</span>
|
||||
<div class="segmented" role="group" aria-label="Group strips by">
|
||||
<button type="button" class="seg is-active" aria-pressed="true">State</button>
|
||||
<button type="button" class="seg" aria-pressed="false">Project</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="needs-pill" id="needsPill"></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="bays" id="bays"></main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="legend-keys" id="legendKeys"></div>
|
||||
<div class="legend-motion">
|
||||
<span class="legend-sample sample-blink" aria-hidden="true"></span>
|
||||
fast blink = needs you
|
||||
<span class="legend-dot">·</span>
|
||||
<span class="legend-sample sample-pulse" aria-hidden="true"></span>
|
||||
slow pulse = awaiting review
|
||||
<span class="legend-dot">·</span>
|
||||
<span class="legend-sample sample-still" aria-hidden="true"></span>
|
||||
still = settled
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-strips.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,565 @@
|
||||
/* Fleet · Strips — every row is a flight strip that stays in its bay until it is
|
||||
acted on. Ages come from FLEET.now (never Date.now) so reloads are identical. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const F = window.FLEET;
|
||||
if (!F) throw new Error('fleet-state-synthesis.js must be loaded first');
|
||||
|
||||
const IS_MAC = navigator.userAgent.includes('Mac');
|
||||
const MOD = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
const SCOPES = ['This once', 'This project', 'Always'];
|
||||
|
||||
// ---- deterministic pre-expanded rows (the states this mock is meant to show) ----
|
||||
const attention = F.agents.filter((a) => a.state === 'attention');
|
||||
const failed = F.agents.filter((a) => a.state === 'failed');
|
||||
const reviewByFinish = F.buckets.review.slice().sort((x, y) => y.completedAt - x.completedAt);
|
||||
const doneByFinish = F.buckets.done.slice().sort((x, y) => y.completedAt - x.completedAt);
|
||||
|
||||
const peekAgent = attention.find((a) => a.pinned) || attention[0];
|
||||
// Hover preview on the top (pinned) review strip — the only one above the rack fold.
|
||||
const hoverAgent =
|
||||
reviewByFinish.slice().sort((x, y) => (y.pinned ? 1 : 0) - (x.pinned ? 1 : 0))[0];
|
||||
|
||||
const ui = {
|
||||
pins: new Set(F.agents.filter((a) => a.pinned).map((a) => a.id)),
|
||||
peekId: peekAgent.id,
|
||||
focusId: peekAgent.id,
|
||||
hoverId: hoverAgent.id,
|
||||
answered: new Map(),
|
||||
acked: new Set(),
|
||||
reviewed: new Set(),
|
||||
resolved: new Map(),
|
||||
scopes: new Map(F.decisions.map((d) => [d.key, 0])),
|
||||
openDecision: F.decisions[0].key,
|
||||
doneUnfolded: false,
|
||||
};
|
||||
|
||||
// ---- tiny DOM helpers (textContent only — agent names carry quotes and backticks) ----
|
||||
function h(tag, cls, text) {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
function kbd(text) {
|
||||
return h('kbd', null, text);
|
||||
}
|
||||
function btn(cls, label, onClick) {
|
||||
const b = h('button', 'btn ' + cls, label);
|
||||
b.type = 'button';
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
function pinFirst(list) {
|
||||
return list.slice().sort((x, y) => (ui.pins.has(y.id) ? 1 : 0) - (ui.pins.has(x.id) ? 1 : 0));
|
||||
}
|
||||
|
||||
// ---- counts (live: they drain as work is answered, never by silent removal) ----
|
||||
const resolvedAgentCount = () =>
|
||||
F.decisions.reduce((n, d) => n + (ui.resolved.has(d.key) ? d.agents.length : 0), 0);
|
||||
const openCount = () => F.counts.needsYou - ui.answered.size - resolvedAgentCount();
|
||||
// ISA-18.2: acknowledging silences the annunciation, it does not close the item.
|
||||
const unackedCount = () => openCount() - ui.acked.size;
|
||||
const unreviewedCount = () => F.counts.review - ui.reviewed.size;
|
||||
|
||||
// ---- glyph: shape carries state, fill carries process liveness ----
|
||||
function glyph(a, stateOverride) {
|
||||
const state = stateOverride || a.state;
|
||||
const g = h('span', 'glyph state-' + state + (a.alive ? '' : ' is-exited'));
|
||||
if (state === 'working') {
|
||||
g.appendChild(h('span', a.loopSleeping ? 'crescent' : 'ring'));
|
||||
g.title = a.loopSleeping
|
||||
? 'loop sleeping — wakes on schedule'
|
||||
: a.alive
|
||||
? 'working'
|
||||
: 'process exited — a reply restarts it';
|
||||
} else {
|
||||
g.classList.add('g-char');
|
||||
g.textContent = F.stateGlyph(state);
|
||||
g.title = state + (a.alive ? ' · running' : ' · process exited, a reply restarts it');
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function sleepNote(a) {
|
||||
return 'sleeping · next run in ' + ((F.hash(a.id) % 17) + 4) + 'm';
|
||||
}
|
||||
|
||||
// ---- strip ----------------------------------------------------------------
|
||||
function strip(a, cfg) {
|
||||
const state = cfg.state || a.state;
|
||||
const group = h('div', 'strip-group state-' + state);
|
||||
group.dataset.id = a.id;
|
||||
if (ui.pins.has(a.id)) group.classList.add('is-pinned');
|
||||
if (cfg.blink) group.classList.add('is-blinking');
|
||||
if (cfg.pulse) group.classList.add('is-pulsing');
|
||||
if (ui.focusId === a.id) group.classList.add('is-focused');
|
||||
if (cfg.hovered) group.classList.add('is-hovered');
|
||||
|
||||
const row = h('div', 'strip');
|
||||
row.appendChild(glyph(a, state));
|
||||
row.appendChild(h('span', 'chip-type', a.agentType));
|
||||
row.appendChild(h('span', 'cell-name', a.shortName));
|
||||
row.appendChild(h('span', 'cell-summary', cfg.summary));
|
||||
if (cfg.note) row.appendChild(h('span', 'chip-note', cfg.note));
|
||||
if (cfg.tag) row.appendChild(h('span', 'chip-tag', cfg.tag));
|
||||
|
||||
if (cfg.actions) {
|
||||
const actions = h('div', 'cell-actions');
|
||||
if (cfg.hovered) actions.appendChild(h('span', 'chip-tag', 'hover'));
|
||||
cfg.actions.forEach((act) => actions.appendChild(btn('', act.label, act.run)));
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
row.appendChild(h('span', 'chip-worktree', a.worktree));
|
||||
if (a.pr) row.appendChild(h('span', 'chip-pr', '#' + a.pr.number));
|
||||
row.appendChild(h('span', 'cell-age', cfg.age));
|
||||
|
||||
const pin = h('button', 'pin' + (ui.pins.has(a.id) ? ' is-on' : ''), ui.pins.has(a.id) ? '●' : '○');
|
||||
pin.type = 'button';
|
||||
pin.title = ui.pins.has(a.id) ? 'Unpin' : 'Pin to the top of this bay';
|
||||
pin.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(a.id);
|
||||
});
|
||||
row.appendChild(pin);
|
||||
|
||||
row.addEventListener('click', () => {
|
||||
ui.focusId = a.id;
|
||||
if (a.question && !ui.answered.has(a.id)) ui.peekId = ui.peekId === a.id ? null : a.id;
|
||||
else if (a.failure) ui.acked.add(a.id);
|
||||
render();
|
||||
});
|
||||
if (a.failure && !ui.acked.has(a.id)) {
|
||||
row.title = 'Click to acknowledge — the strip stays until the failure is resolved';
|
||||
}
|
||||
group.appendChild(row);
|
||||
|
||||
if (ui.peekId === a.id && a.question && !ui.answered.has(a.id)) group.appendChild(peek(a));
|
||||
return group;
|
||||
}
|
||||
|
||||
// Peek expands in place and pushes the rack down — the strip never leaves its slot.
|
||||
function peek(a) {
|
||||
const box = h('div', 'peek');
|
||||
box.appendChild(h('p', 'peek-question', a.question.text));
|
||||
|
||||
const actions = h('div', 'peek-actions');
|
||||
a.question.options.forEach((label, i) => {
|
||||
const b = btn('peek-option', null, () => answer(a.id, label));
|
||||
b.appendChild(kbd(String(i + 1)));
|
||||
b.appendChild(h('span', null, label));
|
||||
actions.appendChild(b);
|
||||
});
|
||||
actions.appendChild(h('span', 'peek-divider'));
|
||||
const input = h('input', 'peek-input');
|
||||
input.placeholder = 'Reply to ' + a.shortName.slice(0, 28) + '…';
|
||||
actions.appendChild(input);
|
||||
actions.appendChild(btn('btn-primary', 'Send', () => answer(a.id, 'custom reply')));
|
||||
box.appendChild(actions);
|
||||
|
||||
const hint = h('div', 'peek-hint');
|
||||
[
|
||||
[kbd('Space'), 'peek'],
|
||||
[kbd('⏎'), 'attach'],
|
||||
[kbd('esc'), 'close'],
|
||||
].forEach(([k, label], i) => {
|
||||
if (i) hint.appendChild(h('span', 'legend-dot', '·'));
|
||||
hint.appendChild(k);
|
||||
hint.appendChild(h('span', null, label));
|
||||
});
|
||||
box.appendChild(hint);
|
||||
box.addEventListener('click', (e) => e.stopPropagation());
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---- coalesced decision card: one answer unblocks several agents ----------
|
||||
function decisionCard(d) {
|
||||
const card = h('div', 'decision-card');
|
||||
const resolution = ui.resolved.get(d.key);
|
||||
|
||||
if (resolution) {
|
||||
const row = h('div', 'dc-resolved');
|
||||
const g = h('span', 'glyph g-char', '✔');
|
||||
row.appendChild(g);
|
||||
row.appendChild(h('span', null, resolution.verb + ' ' + d.command + ' · ' + resolution.scope));
|
||||
row.appendChild(h('span', 'dc-meta', d.agents.length + ' agents resumed'));
|
||||
row.appendChild(
|
||||
btn('', 'Undo', () => {
|
||||
ui.resolved.delete(d.key);
|
||||
render();
|
||||
}),
|
||||
);
|
||||
card.appendChild(row);
|
||||
return card;
|
||||
}
|
||||
|
||||
const open = ui.openDecision === d.key;
|
||||
card.classList.add('is-live');
|
||||
if (!open) card.classList.add('is-collapsed');
|
||||
|
||||
const head = h('div', 'dc-head');
|
||||
head.appendChild(glyph(d.agents[0]));
|
||||
|
||||
const title = h('span', 'dc-title');
|
||||
d.title.split('`').forEach((part, i) => {
|
||||
if (!part) return;
|
||||
title.appendChild(i % 2 ? h('code', null, part) : h('span', null, part));
|
||||
});
|
||||
head.appendChild(title);
|
||||
head.appendChild(h('span', 'dc-cmd', d.command));
|
||||
if (open) head.appendChild(h('span', 'dc-detail', d.detail));
|
||||
|
||||
const projects = [...new Set(d.agents.map((a) => a.displayProject))];
|
||||
head.appendChild(h('span', 'dc-meta', d.agents.length + ' agents · ' + projects.join(' · ')));
|
||||
card.appendChild(head);
|
||||
|
||||
const controls = h('div', open ? 'dc-controls' : 'dc-controls dc-inline');
|
||||
controls.appendChild(h('span', 'eyebrow', 'Scope'));
|
||||
const seg = h('div', 'segmented');
|
||||
SCOPES.forEach((label, i) => {
|
||||
const s = h('button', 'seg' + (ui.scopes.get(d.key) === i ? ' is-active' : ''), label);
|
||||
s.type = 'button';
|
||||
s.addEventListener('click', () => {
|
||||
ui.scopes.set(d.key, i);
|
||||
render();
|
||||
});
|
||||
seg.appendChild(s);
|
||||
});
|
||||
controls.appendChild(seg);
|
||||
controls.appendChild(h('span', 'spacer'));
|
||||
|
||||
const toggle = h('button', 'dc-expand', d.agents.length + (open ? ' agents ▾' : ' agents ▸'));
|
||||
toggle.type = 'button';
|
||||
toggle.addEventListener('click', () => {
|
||||
ui.openDecision = open ? null : d.key;
|
||||
render();
|
||||
});
|
||||
controls.appendChild(toggle);
|
||||
controls.appendChild(btn('', 'Deny', () => resolve(d, 'Denied')));
|
||||
controls.appendChild(btn('btn-primary', 'Allow', () => resolve(d, 'Allowed')));
|
||||
|
||||
if (open) {
|
||||
card.appendChild(controls);
|
||||
const list = h('div', 'dc-agents');
|
||||
d.agents.forEach((a) => {
|
||||
const r = h('div', 'dc-agent state-' + a.state);
|
||||
r.style.setProperty('--strip-state', 'var(' + F.stateColorVar(a.state) + ')');
|
||||
r.appendChild(glyph(a));
|
||||
r.appendChild(h('span', 'dc-agent-name', a.shortName));
|
||||
r.appendChild(h('span', 'chip-worktree', a.worktree));
|
||||
r.appendChild(h('span', 'spacer'));
|
||||
r.appendChild(h('span', 'cell-age', F.ageLabel(a.startedAgo)));
|
||||
list.appendChild(r);
|
||||
});
|
||||
card.appendChild(list);
|
||||
} else {
|
||||
head.appendChild(controls);
|
||||
controls.classList.add('dc-inline');
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
function resolve(d, verb) {
|
||||
ui.resolved.set(d.key, { verb: verb, scope: SCOPES[ui.scopes.get(d.key)].toLowerCase() });
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- bays -----------------------------------------------------------------
|
||||
function bay(key, name, count, affordance, note) {
|
||||
const el = h('section', 'bay bay-' + key);
|
||||
const head = h('div', 'bay-head');
|
||||
head.appendChild(h('span', 'eyebrow bay-name', name));
|
||||
head.appendChild(h('span', 'count-badge', String(count)));
|
||||
if (affordance) head.appendChild(affordance);
|
||||
if (note) head.appendChild(h('span', 'bay-note', note));
|
||||
el.appendChild(head);
|
||||
const body = h('div', 'bay-body');
|
||||
body.dataset.bay = key;
|
||||
el.appendChild(body);
|
||||
el.body = body;
|
||||
return el;
|
||||
}
|
||||
|
||||
function affordance(cls, parts) {
|
||||
const wrap = h('span', 'bay-affordance' + (cls ? ' ' + cls : ''));
|
||||
parts.forEach((p) => wrap.appendChild(typeof p === 'string' ? h('span', null, p) : p));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function needsYouBay() {
|
||||
const el = bay(
|
||||
'needsYou',
|
||||
'Needs you',
|
||||
openCount(),
|
||||
affordance(null, [kbd('F'), 'next needs-you', h('span', 'legend-dot', '·'), 'answer once, unblock many']),
|
||||
ui.acked.size ? ui.acked.size + ' acknowledged · still open until resolved' : null,
|
||||
);
|
||||
F.decisions.forEach((d) => el.body.appendChild(decisionCard(d)));
|
||||
|
||||
// Severity order: failures outrank questions (FLEET.severityRank).
|
||||
failed.forEach((a) => {
|
||||
const acked = ui.acked.has(a.id);
|
||||
el.body.appendChild(
|
||||
strip(a, {
|
||||
summary: a.failure,
|
||||
age: F.ageLabel(a.startedAgo),
|
||||
blink: !acked,
|
||||
tag: acked ? 'acked' : null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
pinFirst(attention).forEach((a) => {
|
||||
const answered = ui.answered.get(a.id);
|
||||
el.body.appendChild(
|
||||
strip(a, {
|
||||
// Answered strips do not vanish — they change state in place and go quiet.
|
||||
state: answered ? 'working' : 'attention',
|
||||
summary: answered ? 'Answered: ' + answered + ' · resuming' : a.question.text,
|
||||
age: F.ageLabel(a.startedAgo),
|
||||
blink: !answered,
|
||||
tag: answered ? 'answered' : null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function workingBay() {
|
||||
const exited = F.buckets.working.filter((a) => !a.alive).length;
|
||||
const sleeping = F.buckets.working.filter((a) => a.loopSleeping).length;
|
||||
const el = bay(
|
||||
'working',
|
||||
'Working',
|
||||
F.counts.working,
|
||||
affordance(null, [
|
||||
F.counts.working - exited - sleeping + ' running',
|
||||
h('span', 'legend-dot', '·'),
|
||||
exited + ' exited',
|
||||
h('span', 'legend-dot', '·'),
|
||||
sleeping + ' sleeping',
|
||||
]),
|
||||
'no motion here — working is the quiet state',
|
||||
);
|
||||
// Longest-running first; the sleeping loop rides on top because it is not
|
||||
// holding a slot, and pins outrank both.
|
||||
const order = F.buckets.working
|
||||
.slice()
|
||||
.sort((x, y) => (y.loopSleeping ? 1 : 0) - (x.loopSleeping ? 1 : 0) || y.startedAgo - x.startedAgo);
|
||||
pinFirst(order).forEach((a) => {
|
||||
el.body.appendChild(
|
||||
strip(a, {
|
||||
summary: a.detail || 'Working',
|
||||
note: a.loopSleeping ? sleepNote(a) : a.alive ? null : 'exited · reply restarts',
|
||||
age: F.ageLabel(a.startedAgo),
|
||||
}),
|
||||
);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function reviewBay() {
|
||||
const el = bay(
|
||||
'review',
|
||||
'To review',
|
||||
F.counts.review,
|
||||
affordance('is-latched', [
|
||||
unreviewedCount() + ' unreviewed',
|
||||
h('span', 'legend-dot', '·'),
|
||||
kbd('R'),
|
||||
'review',
|
||||
h('span', 'legend-dot', '·'),
|
||||
kbd('⏎'),
|
||||
'cycle',
|
||||
]),
|
||||
'seen ≠ reviewed — strips stay until reviewed',
|
||||
);
|
||||
pinFirst(reviewByFinish).forEach((a) => {
|
||||
const done = ui.reviewed.has(a.id);
|
||||
const tail = F.fakeTail(a);
|
||||
el.body.appendChild(
|
||||
strip(a, {
|
||||
summary: (done ? 'Reviewed · ' : 'Finished · ') + tail[tail.length - 1],
|
||||
age: F.ageLabel(a.durationMs) + ' run',
|
||||
pulse: !done,
|
||||
tag: done ? 'reviewed' : null,
|
||||
hovered: !done && ui.hoverId === a.id,
|
||||
actions:
|
||||
!done && ui.hoverId === a.id
|
||||
? [
|
||||
{ label: 'Mark reviewed', run: () => markReviewed(a.id) },
|
||||
{ label: 'Review diff', run: () => {} },
|
||||
]
|
||||
: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function doneBay() {
|
||||
const withPr = doneByFinish.filter((a) => a.pr).slice(0, 2);
|
||||
const rest = doneByFinish.filter((a) => !withPr.includes(a)).slice(0, 2);
|
||||
const visible = ui.doneUnfolded
|
||||
? doneByFinish
|
||||
: withPr.concat(rest).sort((x, y) => y.completedAt - x.completedAt);
|
||||
const hidden = F.counts.done - visible.length;
|
||||
|
||||
const el = bay(
|
||||
'done',
|
||||
'Done',
|
||||
F.counts.done,
|
||||
affordance(null, [doneByFinish.filter((a) => a.pr).length + ' with PRs', h('span', 'legend-dot', '·'), 'ages frozen at finish']),
|
||||
);
|
||||
visible.forEach((a) => {
|
||||
const tail = F.fakeTail(a);
|
||||
el.body.appendChild(
|
||||
strip(a, { summary: 'Finished · ' + tail[tail.length - 1], age: F.ageLabel(a.durationMs) + ' run' }),
|
||||
);
|
||||
});
|
||||
|
||||
if (hidden > 0 || ui.doneUnfolded) {
|
||||
const fold = h('div', 'fold-row');
|
||||
fold.appendChild(h('span', 'fold-count', ui.doneUnfolded ? '▾ fold ' + F.counts.done + ' done' : '… ' + hidden + ' more'));
|
||||
fold.appendChild(h('span', 'legend-dot', '·'));
|
||||
fold.appendChild(h('span', null, 'PRs and failures never fold'));
|
||||
fold.appendChild(h('span', 'fold-rule', ui.doneUnfolded ? 'showing all' : 'click to unfold'));
|
||||
fold.addEventListener('click', () => {
|
||||
ui.doneUnfolded = !ui.doneUnfolded;
|
||||
render();
|
||||
});
|
||||
el.body.appendChild(fold);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---- actions --------------------------------------------------------------
|
||||
function togglePin(id) {
|
||||
if (ui.pins.has(id)) ui.pins.delete(id);
|
||||
else ui.pins.add(id);
|
||||
render();
|
||||
}
|
||||
function answer(id, label) {
|
||||
ui.answered.set(id, label);
|
||||
ui.peekId = null;
|
||||
render();
|
||||
}
|
||||
function markReviewed(id) {
|
||||
ui.reviewed.add(id);
|
||||
if (ui.hoverId === id) {
|
||||
const next = pinFirst(reviewByFinish).find((a) => !ui.reviewed.has(a.id));
|
||||
ui.hoverId = next ? next.id : null;
|
||||
}
|
||||
render();
|
||||
}
|
||||
function needsYouStrips() {
|
||||
return pinFirst(attention).concat(failed).filter((a) => !ui.answered.has(a.id));
|
||||
}
|
||||
function cycleNeedsYou() {
|
||||
const list = needsYouStrips();
|
||||
if (!list.length) return;
|
||||
const i = list.findIndex((a) => a.id === ui.focusId);
|
||||
const next = list[(i + 1) % list.length];
|
||||
ui.focusId = next.id;
|
||||
ui.peekId = next.question ? next.id : null;
|
||||
render(next.id);
|
||||
}
|
||||
function cycleReview() {
|
||||
const list = pinFirst(reviewByFinish).filter((a) => !ui.reviewed.has(a.id));
|
||||
if (!list.length) return;
|
||||
const i = list.findIndex((a) => a.id === ui.hoverId);
|
||||
ui.hoverId = list[(i + 1) % list.length].id;
|
||||
render(ui.hoverId);
|
||||
}
|
||||
|
||||
// ---- chrome ---------------------------------------------------------------
|
||||
function renderTopbar() {
|
||||
document.getElementById('accounting').textContent =
|
||||
F.counts.total + ' of ' + F.counts.fleetTotal + ' agents · ' +
|
||||
F.byWorktree.length + ' of ' + F.counts.fleetWorktrees + ' worktrees';
|
||||
|
||||
const pill = document.getElementById('needsPill');
|
||||
pill.textContent = '';
|
||||
const n = unackedCount();
|
||||
pill.title = 'Unacknowledged work. Acknowledged failures stay in the bay until they are resolved.';
|
||||
pill.classList.toggle('is-clear', n === 0);
|
||||
pill.appendChild(h('span', 'pill-glyph', n === 0 ? '✔' : '?'));
|
||||
pill.appendChild(h('span', null, n === 0 ? 'all clear' : n + ' need you'));
|
||||
if (n > 0) {
|
||||
pill.appendChild(h('span', 'pill-sep', '·'));
|
||||
pill.appendChild(kbd('⏎'));
|
||||
pill.appendChild(h('span', 'pill-sep', 'next'));
|
||||
}
|
||||
}
|
||||
|
||||
function renderFooter() {
|
||||
const keys = document.getElementById('legendKeys');
|
||||
keys.textContent = '';
|
||||
[
|
||||
['Space', 'peek'],
|
||||
['⏎', 'attach'],
|
||||
['1–3', 'answer'],
|
||||
['P', 'pin'],
|
||||
['R', 'mark reviewed'],
|
||||
['F', 'next needs-you'],
|
||||
[MOD + 'K', 'search'],
|
||||
].forEach(([k, label], i) => {
|
||||
if (i) keys.appendChild(h('span', 'sep', '·'));
|
||||
keys.appendChild(kbd(k));
|
||||
keys.appendChild(h('span', null, label));
|
||||
});
|
||||
}
|
||||
|
||||
const baysEl = document.getElementById('bays');
|
||||
function render(scrollToId) {
|
||||
const scroll = {};
|
||||
baysEl.querySelectorAll('.bay-body').forEach((b) => {
|
||||
scroll[b.dataset.bay] = b.scrollTop;
|
||||
});
|
||||
baysEl.textContent = '';
|
||||
[needsYouBay(), workingBay(), reviewBay(), doneBay()].forEach((b) => {
|
||||
baysEl.appendChild(b);
|
||||
if (scroll[b.body.dataset.bay]) b.body.scrollTop = scroll[b.body.dataset.bay];
|
||||
});
|
||||
renderTopbar();
|
||||
if (scrollToId) {
|
||||
const el = baysEl.querySelector('[data-id="' + CSS.escape(scrollToId) + '"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('needsPill').addEventListener('click', cycleNeedsYou);
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target instanceof HTMLInputElement) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const focused = F.agents.find((a) => a.id === ui.focusId);
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (focused && focused.question) ui.peekId = ui.peekId === focused.id ? null : focused.id;
|
||||
render();
|
||||
} else if (e.key === 'Escape') {
|
||||
ui.peekId = null;
|
||||
render();
|
||||
} else if (e.key === 'Enter' || e.key === 'f' || e.key === 'F') {
|
||||
cycleNeedsYou();
|
||||
} else if (e.key === 'p' || e.key === 'P') {
|
||||
if (focused) togglePin(focused.id);
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
if (ui.hoverId) markReviewed(ui.hoverId);
|
||||
else cycleReview();
|
||||
} else if (/^[1-3]$/.test(e.key)) {
|
||||
const peeked = F.agents.find((a) => a.id === ui.peekId);
|
||||
const option = peeked && peeked.question.options[Number(e.key) - 1];
|
||||
if (option) answer(peeked.id, option);
|
||||
}
|
||||
});
|
||||
|
||||
renderFooter();
|
||||
render();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,703 @@
|
||||
/* Fleet Terminal Wall — a video wall of miniature agent terminals.
|
||||
The only text in the field is the agents' own output; chrome is a whisper.
|
||||
Tile geometry is solved in JS and arrives as --cols / --tile-h per band. */
|
||||
|
||||
:root {
|
||||
--tile-pad: 7px;
|
||||
--tile-line: 13px; /* 9px mono × 1.45 */
|
||||
--name-lane: 13px; /* bottom strip reserved for the agent-name whisper */
|
||||
--dim: 0.16;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- top bar --------------------------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tabs a {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tabs a.is-active {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chip .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chip .n {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip .lbl {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip.is-loud .dot {
|
||||
opacity: 1;
|
||||
box-shadow: var(--glow-attention);
|
||||
}
|
||||
|
||||
.chip.is-loud .n {
|
||||
color: var(--state-attention);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chips .sep {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topright {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 190px;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.search::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accounting {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- the wall -------------------------------------------------------------- */
|
||||
|
||||
.wall {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 2px 22px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Never shrink: the JS solver has already sized every band to fit the viewport,
|
||||
and a flex shrink here would clip a band mid-tile. */
|
||||
.band-wrap {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.band-label {
|
||||
margin: 0 0 6px;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.band {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
|
||||
grid-auto-rows: var(--tile-h);
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.band-micro {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* ---- terminal tile --------------------------------------------------------- */
|
||||
|
||||
/* Tiles are screens, not cards: --background, not --card. The hairline edge is
|
||||
the only thing separating them from the wall behind. */
|
||||
.tile {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: var(--tile-pad);
|
||||
padding-bottom: calc(var(--tile-pad) + var(--name-lane));
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.tile.is-dim {
|
||||
opacity: var(--dim);
|
||||
}
|
||||
|
||||
/* Bottom-anchored so the newest line sits still and older ones scroll off the top. */
|
||||
.term {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
line-height: var(--tile-line);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.term > div {
|
||||
flex: 0 0 var(--tile-line);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.term .last,
|
||||
.p-tail .last {
|
||||
color: var(--foreground);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.term .last .txt,
|
||||
.p-tail .last .txt {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.cur {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 1px;
|
||||
color: var(--foreground);
|
||||
animation: cursor-blink 1.05s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The one identity concession — a name whisper inside the screen. */
|
||||
.who {
|
||||
position: absolute;
|
||||
left: var(--tile-pad);
|
||||
right: var(--tile-pad);
|
||||
bottom: 4px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
line-height: 11px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Corner mark: shape carries state so hue never rides alone. */
|
||||
.mark {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
color: var(--hue);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mark.dot {
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mark.crescent {
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset -2px 0 0 0 currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* -- per state -- */
|
||||
|
||||
.tile[data-state='working'] {
|
||||
--hue: var(--state-working);
|
||||
}
|
||||
|
||||
/* Static tint under the pulse so reduced-motion still reads the review edge as green. */
|
||||
.tile[data-state='review'] {
|
||||
--hue: var(--state-done);
|
||||
border-color: color-mix(in srgb, var(--state-done) 40%, var(--border));
|
||||
animation: pulse-review var(--pulse-slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tile[data-state='review'] .mark {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tile[data-state='attention'],
|
||||
.tile[data-state='permission'] {
|
||||
--hue: var(--state-attention);
|
||||
}
|
||||
|
||||
.tile[data-state='failed'] {
|
||||
--hue: var(--state-blocked);
|
||||
}
|
||||
|
||||
.tile[data-state='attention'],
|
||||
.tile[data-state='permission'],
|
||||
.tile[data-state='failed'] {
|
||||
border-width: 1.5px;
|
||||
padding: calc(var(--tile-pad) - 0.5px);
|
||||
padding-bottom: calc(var(--tile-pad) + var(--name-lane) - 0.5px);
|
||||
}
|
||||
|
||||
.tile[data-state='attention'],
|
||||
.tile[data-state='permission'] {
|
||||
border-color: var(--state-attention);
|
||||
box-shadow: var(--glow-attention);
|
||||
animation: blink-border-attention var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.tile[data-state='failed'] {
|
||||
border-color: var(--state-blocked);
|
||||
box-shadow: 0 0 7px color-mix(in srgb, var(--state-blocked) 60%, transparent);
|
||||
animation: blink-border-blocked var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
/* The needs-you last line is the loudest text on the wall. */
|
||||
.tile[data-state='attention'] .term .last,
|
||||
.tile[data-state='permission'] .term .last,
|
||||
.tile[data-state='failed'] .term .last {
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
/* -- done: present and accounted for, visually silent -- */
|
||||
|
||||
.tile[data-state='done'] {
|
||||
--hue: var(--state-idle);
|
||||
}
|
||||
|
||||
.tile.micro {
|
||||
padding: 0;
|
||||
border-color: color-mix(in srgb, var(--border) 55%, transparent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.tile.micro .pr {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* -- hover / selection -- */
|
||||
|
||||
.tile:hover,
|
||||
.tile:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--ring) 55%, var(--border));
|
||||
}
|
||||
|
||||
.tile.is-selected {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* ---- footer whisper -------------------------------------------------------- */
|
||||
|
||||
.whisper {
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 22px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.whisper .key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.whisper .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.whisper .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ---- tooltip --------------------------------------------------------------- */
|
||||
|
||||
.tip {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
max-width: 300px;
|
||||
padding: 9px 11px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-floating);
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tip .t-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.tip .t-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.tip .t-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta {
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta .mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- selection panel ------------------------------------------------------- */
|
||||
|
||||
/* Spans the wall, not the window, so the accounting line and the legend survive. */
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 46px;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
z-index: 50;
|
||||
width: 370px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-floating);
|
||||
transform: translateX(101%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 190ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
visibility 190ms;
|
||||
}
|
||||
|
||||
.panel.is-open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 22px 8px;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-name {
|
||||
margin: 0 30px 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.p-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.p-path {
|
||||
margin: 8px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.p-facts {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
margin: 16px 0 14px;
|
||||
}
|
||||
|
||||
.p-facts .v {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.p-tail {
|
||||
padding: 9px 11px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-tail > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.p-respond {
|
||||
padding: 14px 22px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.p-ask {
|
||||
margin: 0 0 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.p-cmd {
|
||||
margin: 0 0 11px;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-fail {
|
||||
margin: 0 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
|
||||
.opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
background: var(--secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt:hover {
|
||||
border-color: var(--input);
|
||||
}
|
||||
|
||||
.opt .num {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.reply input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reply input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply input:focus {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.btnrow {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.btn.is-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.btn.is-primary:hover {
|
||||
background: var(--foreground);
|
||||
}
|
||||
|
||||
.btn.is-danger:hover {
|
||||
border-color: color-mix(in srgb, var(--state-blocked) 55%, var(--border));
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Fleet — Wall</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-terminal-wall.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<span class="brand">Fleet</span>
|
||||
<nav class="tabs" aria-label="Fleet views">
|
||||
<a href="./fleet-map.html">Map</a>
|
||||
<a href="./fleet-strips.html">Strips</a>
|
||||
<a href="./mission-control.html">Mission Control</a>
|
||||
<a href="./fleet-hexmap.html">Hexmap</a>
|
||||
<a class="is-active" href="./fleet-terminal-wall.html" aria-current="page">Wall</a>
|
||||
</nav>
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="topright">
|
||||
<input
|
||||
id="search"
|
||||
class="search"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filter the fleet"
|
||||
/>
|
||||
<span class="accounting" id="accounting"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wall" id="wall"></div>
|
||||
|
||||
<footer class="whisper" id="whisper"></footer>
|
||||
</main>
|
||||
|
||||
<aside class="panel" id="panel" aria-label="Agent detail" aria-hidden="true"></aside>
|
||||
<div class="tip" id="tip" role="tooltip" hidden></div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-terminal-wall.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,497 @@
|
||||
/* Fleet Terminal Wall — 104 miniature terminals, one per agent, tiled as a video wall.
|
||||
The only text in the field is the agents' own output. Working tiles type; needs-you
|
||||
tiles are frozen on the line that needs a human; done tiles are silent dark rectangles.
|
||||
Ages come from FLEET.now; timers control pacing only, never content. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const F = window.FLEET;
|
||||
if (!F) throw new Error('fleet-state-synthesis.js must be loaded first');
|
||||
|
||||
const IS_MAC = navigator.userAgent.includes('Mac');
|
||||
const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const STATE_LABEL = {
|
||||
attention: 'Waiting on your answer',
|
||||
permission: 'Waiting on permission',
|
||||
failed: 'Failed',
|
||||
working: 'Working',
|
||||
review: 'Ready to review',
|
||||
done: 'Done',
|
||||
};
|
||||
|
||||
// Plausible terminal output. {detail} / {wt} are filled per agent so tool-call lines
|
||||
// echo what that agent is actually doing.
|
||||
const WORK_LINES = [
|
||||
'$ pnpm vitest run src/renderer/src/components/dashboard-popout',
|
||||
' ✓ 18 tests passed (412ms)',
|
||||
'$ git add -A',
|
||||
'$ git commit -m "fix(dashboard): keep retained rows stable"',
|
||||
'Reading src/shared/dashboard-snapshot.ts…',
|
||||
'Editing src/renderer/src/components/dashboard/agent-row-lineage.ts',
|
||||
' + carried stateChangedAt through the retained-row path',
|
||||
'$ pnpm oxlint src/main/ipc --max-warnings 0',
|
||||
' Found 0 warnings and 0 errors.',
|
||||
' RUN v3.2.4 ~/orca/{wt}',
|
||||
'⏺ {detail}',
|
||||
'$ git diff --stat',
|
||||
' 3 files changed, 96 insertions(+), 22 deletions(-)',
|
||||
'Searching for "buildDashboardSnapshot" — 14 matches',
|
||||
'$ pnpm typecheck',
|
||||
' tsc --noEmit … done in 21.4s',
|
||||
'Editing src/main/startup/configure-process.ts',
|
||||
'$ gh pr checks --watch',
|
||||
' verify (renderer) pass 2m14s',
|
||||
' ✓ groups cards by bucket (18ms)',
|
||||
'· {detail}',
|
||||
'$ git status --porcelain=v2 --branch',
|
||||
'Reading docs/reference/git-compatibility.md…',
|
||||
'Writing src/renderer/src/components/dashboard-popout/activity-lane-model.ts',
|
||||
];
|
||||
|
||||
const MAX_LINES = 14;
|
||||
const GAP = 8;
|
||||
const MICRO_GAP = 5;
|
||||
const MICRO_H = 28;
|
||||
const LABEL_H = 18; // 12px eyebrow line + 6px margin
|
||||
const C_RATIO = 0.64; // review tiles read as a smaller sibling of the big screens
|
||||
const COLS = { a: 6, b: 6, c: 7, d: 32 };
|
||||
|
||||
const el = {
|
||||
wall: document.getElementById('wall'),
|
||||
chips: document.getElementById('chips'),
|
||||
accounting: document.getElementById('accounting'),
|
||||
whisper: document.getElementById('whisper'),
|
||||
search: document.getElementById('search'),
|
||||
panel: document.getElementById('panel'),
|
||||
tip: document.getElementById('tip'),
|
||||
};
|
||||
|
||||
const byId = new Map(F.agents.map((a) => [a.id, a]));
|
||||
const nodeOf = new Map();
|
||||
const streams = new Map();
|
||||
let selectedId = null;
|
||||
|
||||
const esc = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c],
|
||||
);
|
||||
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const liveness = (a) =>
|
||||
a.loopSleeping ? 'loop sleeping' : a.alive ? 'process live' : 'process exited';
|
||||
const fill = (line, a) => line.replace('{detail}', a.detail || 'thinking').replace('{wt}', a.worktree);
|
||||
|
||||
// ---- band membership -------------------------------------------------------
|
||||
|
||||
// Spatial stability: byWorktree order (alpha worktree, then startedAt) is the base
|
||||
// rank everywhere; NEEDS YOU layers severity on top so failures lead.
|
||||
const rank = new Map();
|
||||
let k = 0;
|
||||
for (const g of F.byWorktree) for (const a of g.agents) rank.set(a.id, k++);
|
||||
const byRank = (x, y) => rank.get(x.id) - rank.get(y.id);
|
||||
|
||||
const BANDS = [
|
||||
{
|
||||
key: 'a',
|
||||
label: 'NEEDS YOU',
|
||||
agents: F.buckets.needsYou
|
||||
.slice()
|
||||
.sort((x, y) => F.severityRank(x.state) - F.severityRank(y.state) || byRank(x, y)),
|
||||
},
|
||||
{ key: 'b', label: 'WORKING', agents: F.buckets.working.slice().sort(byRank) },
|
||||
{ key: 'c', label: 'TO REVIEW', agents: F.buckets.review.slice().sort(byRank) },
|
||||
{ key: 'd', label: 'DONE', agents: F.buckets.done.slice().sort(byRank) },
|
||||
];
|
||||
const gridOf = {};
|
||||
|
||||
// ---- chrome ----------------------------------------------------------------
|
||||
|
||||
function renderChrome() {
|
||||
const c = F.counts;
|
||||
const chips = [
|
||||
{ n: c.needsYou, lbl: 'need you', hue: 'var(--state-attention)', loud: true },
|
||||
{ n: c.working, lbl: 'working', hue: 'var(--state-working)' },
|
||||
{ n: c.review, lbl: 'to review', hue: 'var(--state-done)' },
|
||||
{ n: c.done, lbl: 'done', hue: 'var(--state-idle)' },
|
||||
];
|
||||
el.chips.innerHTML = chips
|
||||
.map(
|
||||
(x) =>
|
||||
'<span class="chip' + (x.loud ? ' is-loud' : '') + '" style="--hue:' + x.hue + '">' +
|
||||
'<i class="dot"></i><span class="n">' + x.n + '</span>' +
|
||||
'<span class="lbl">' + x.lbl + '</span></span>',
|
||||
)
|
||||
.join('<span class="sep">·</span>');
|
||||
|
||||
el.search.placeholder = 'Filter… ' + (IS_MAC ? '⌘K' : 'Ctrl+K');
|
||||
el.accounting.textContent =
|
||||
c.total + ' / ' + c.fleetTotal + ' agents · ' +
|
||||
F.byWorktree.length + ' / ' + c.fleetWorktrees + ' worktrees';
|
||||
|
||||
const keys = [
|
||||
['var(--state-attention)', 'amber = needs you'],
|
||||
['var(--state-working)', 'gold = working'],
|
||||
['var(--state-done)', 'green = review'],
|
||||
['var(--state-idle)', 'dark = done'],
|
||||
];
|
||||
el.whisper.innerHTML =
|
||||
keys
|
||||
.map((x) => '<span class="key"><i class="dot" style="--hue:' + x[0] + '"></i>' + x[1] + '</span>')
|
||||
.join('<span class="sep">·</span>') +
|
||||
'<span class="sep">·</span><span>motion = output</span>';
|
||||
}
|
||||
|
||||
// ---- line streams ----------------------------------------------------------
|
||||
|
||||
function makeStream(a) {
|
||||
const h = F.hash(a.id);
|
||||
const st = {
|
||||
agent: a,
|
||||
h,
|
||||
i: 0,
|
||||
stride: 1 + (h % 5), // each tile walks the pool on its own step
|
||||
lines: F.fakeTail(a).slice(),
|
||||
cursor: a.state === 'working' && !a.loopSleeping,
|
||||
};
|
||||
if (a.state === 'permission') {
|
||||
const d = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
st.lines = pull(st, 3).concat(['', 'Waiting for permission: $ ' + (d ? d.command : '')]);
|
||||
} else if (a.state === 'failed') {
|
||||
st.lines = pull(st, 2).concat(['$ ' + F.decisions[0].command, '', a.failure]);
|
||||
} else if (a.loopSleeping) {
|
||||
st.lines = st.lines.concat(['', '☾ sleeping · next run 4m']);
|
||||
} else if (a.state === 'working') {
|
||||
// Stagger the starting depth so the first frame already looks mid-work.
|
||||
st.lines = st.lines.concat(pull(st, 1 + (h % 8)));
|
||||
}
|
||||
trim(st);
|
||||
streams.set(a.id, st);
|
||||
return st;
|
||||
}
|
||||
|
||||
function pull(st, n) {
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push(fill(WORK_LINES[(st.h + st.i * st.stride) % WORK_LINES.length], st.agent));
|
||||
st.i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function trim(st) {
|
||||
if (st.lines.length > MAX_LINES) st.lines = st.lines.slice(-MAX_LINES);
|
||||
}
|
||||
|
||||
// The newest line ellipsizes instead of hard-clipping, so the cursor — the whole
|
||||
// "this one is alive" signal — never falls off the right edge of a narrow tile.
|
||||
function lastLineHtml(st, text) {
|
||||
return (
|
||||
'<div class="last"><span class="txt">' + (esc(text) || ' ') + '</span>' +
|
||||
(st.cursor ? '<i class="cur">▍</i>' : '') + '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function termHtml(st) {
|
||||
return st.lines
|
||||
.map((l, i) =>
|
||||
i === st.lines.length - 1 ? lastLineHtml(st, l) : '<div>' + esc(l) + '</div>',
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function paint(st) {
|
||||
const node = nodeOf.get(st.agent.id);
|
||||
const term = node && node.querySelector('.term');
|
||||
if (term) term.innerHTML = termHtml(st);
|
||||
if (selectedId === st.agent.id) paintPanelTail(st);
|
||||
}
|
||||
|
||||
// Pacing only — jittered start and period so 16 tiles never fall into lockstep.
|
||||
function startTyping(st) {
|
||||
if (REDUCED) return;
|
||||
const period = 900 + (st.h % 1700);
|
||||
window.setTimeout(() => {
|
||||
const tick = () => {
|
||||
st.lines = st.lines.concat(pull(st, 1));
|
||||
trim(st);
|
||||
paint(st);
|
||||
};
|
||||
tick();
|
||||
window.setInterval(tick, period);
|
||||
}, Math.random() * 1800);
|
||||
}
|
||||
|
||||
// ---- tiles -----------------------------------------------------------------
|
||||
|
||||
function markHtml(a) {
|
||||
if (a.loopSleeping) return '<i class="mark crescent"></i>';
|
||||
if (a.state === 'working') return '<i class="mark dot"></i>';
|
||||
if (a.state === 'review') return '<span class="mark">✔</span>';
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildTile(a, micro) {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'tile' + (micro ? ' micro' : '');
|
||||
node.dataset.agent = a.id;
|
||||
node.dataset.state = a.state;
|
||||
node.tabIndex = 0;
|
||||
node.setAttribute('role', 'button');
|
||||
node.setAttribute(
|
||||
'aria-label',
|
||||
a.shortName + ' — ' + STATE_LABEL[a.state] + ', ' + a.displayProject + '/' + a.worktree,
|
||||
);
|
||||
if (micro) {
|
||||
node.innerHTML = a.pr ? '<span class="pr">#</span>' : '';
|
||||
} else {
|
||||
const st = makeStream(a);
|
||||
node.innerHTML =
|
||||
markHtml(a) +
|
||||
'<div class="term">' + termHtml(st, false) + '</div>' +
|
||||
'<div class="who">' + esc(a.shortName) + '</div>';
|
||||
if (a.state === 'working' && !a.loopSleeping) startTyping(st);
|
||||
}
|
||||
nodeOf.set(a.id, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function renderWall() {
|
||||
for (const band of BANDS) {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'band-wrap';
|
||||
const label = document.createElement('p');
|
||||
label.className = 'eyebrow band-label';
|
||||
label.textContent = band.label + ' · ' + band.agents.length;
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'band' + (band.key === 'd' ? ' band-micro' : '');
|
||||
for (const a of band.agents) grid.appendChild(buildTile(a, band.key === 'd'));
|
||||
section.append(label, grid);
|
||||
el.wall.appendChild(section);
|
||||
gridOf[band.key] = grid;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- layout: solve tile height so all 104 tiles fit with no scrolling -------
|
||||
|
||||
function layout() {
|
||||
const cs = getComputedStyle(el.wall);
|
||||
const H = el.wall.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
||||
const rows = {};
|
||||
for (const band of BANDS) rows[band.key] = Math.ceil(band.agents.length / COLS[band.key]);
|
||||
|
||||
const fixed =
|
||||
BANDS.length * LABEL_H +
|
||||
(BANDS.length - 1) * GAP +
|
||||
(rows.a - 1) * GAP +
|
||||
(rows.b - 1) * GAP +
|
||||
(rows.c - 1) * GAP +
|
||||
(rows.d - 1) * MICRO_GAP +
|
||||
rows.d * MICRO_H;
|
||||
|
||||
const free = H - fixed;
|
||||
const h = Math.max(74, Math.min(168, Math.floor(free / (rows.a + rows.b + rows.c * C_RATIO))));
|
||||
|
||||
const heights = { a: h, b: h, c: Math.floor(h * C_RATIO), d: MICRO_H };
|
||||
for (const band of BANDS) {
|
||||
gridOf[band.key].style.setProperty('--cols', COLS[band.key]);
|
||||
gridOf[band.key].style.setProperty('--tile-h', heights[band.key] + 'px');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tooltip ---------------------------------------------------------------
|
||||
|
||||
function showTip(node, a) {
|
||||
el.tip.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')');
|
||||
el.tip.innerHTML =
|
||||
'<div class="t-name">' + esc(a.shortName) + '</div>' +
|
||||
'<div class="t-state">' + STATE_LABEL[a.state] +
|
||||
'<span class="live">· ' + liveness(a) + '</span></div>' +
|
||||
'<div class="t-meta"><span class="mono">' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '</span><br />' +
|
||||
'started ' + F.ageLabel(a.startedAgo) + ' ago · ran ' + F.ageLabel(a.durationMs) +
|
||||
'<br />' + esc(a.detail || '—') + '</div>';
|
||||
el.tip.hidden = false;
|
||||
|
||||
const r = node.getBoundingClientRect();
|
||||
const t = el.tip.getBoundingClientRect();
|
||||
let x = r.left + r.width / 2 - t.width / 2;
|
||||
let y = r.bottom + 8;
|
||||
if (y + t.height > window.innerHeight - 8) y = r.top - t.height - 8;
|
||||
x = Math.max(8, Math.min(x, window.innerWidth - t.width - 8));
|
||||
el.tip.style.left = x + 'px';
|
||||
el.tip.style.top = y + 'px';
|
||||
}
|
||||
|
||||
const hideTip = () => {
|
||||
el.tip.hidden = true;
|
||||
};
|
||||
|
||||
// ---- selection panel -------------------------------------------------------
|
||||
|
||||
const PANEL_LINES = 12;
|
||||
|
||||
function paintPanelTail(st) {
|
||||
const box = el.panel.querySelector('.p-tail');
|
||||
if (!box) return;
|
||||
const shown = st.lines.slice(-PANEL_LINES);
|
||||
const pad = new Array(Math.max(0, PANEL_LINES - shown.length)).fill('').concat(shown);
|
||||
let lastIdx = -1;
|
||||
pad.forEach((l, i) => {
|
||||
if (l.trim()) lastIdx = i;
|
||||
});
|
||||
box.innerHTML = pad
|
||||
.map((l, i) => (i === lastIdx ? lastLineHtml(st, l) : '<div>' + (esc(l) || ' ') + '</div>'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function respondHtml(a) {
|
||||
if (a.state === 'attention') {
|
||||
const opts = a.question.options
|
||||
.map((o, i) => '<button class="opt" type="button"><span class="num">' + (i + 1) + '</span>' + esc(o) + '</button>')
|
||||
.join('');
|
||||
return (
|
||||
'<p class="p-ask">' + esc(a.question.text) + '</p><div class="opts">' + opts + '</div>' +
|
||||
'<div class="reply"><input type="text" placeholder="Or answer in your own words…" aria-label="Reply" />' +
|
||||
'<button class="btn is-primary" type="button">Send</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'permission') {
|
||||
const d = F.decisions.find((x) => x.key === a.decisionKey);
|
||||
return (
|
||||
'<p class="p-cmd">$ ' + esc(d ? d.command : '') + '</p>' +
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Allow once</button>' +
|
||||
'<button class="btn" type="button">Allow for project</button>' +
|
||||
'<button class="btn is-danger" type="button">Deny</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'failed') {
|
||||
return (
|
||||
'<p class="p-fail">' + esc(a.failure) + '</p>' +
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Retry</button>' +
|
||||
'<button class="btn" type="button">Open terminal</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'review') {
|
||||
return (
|
||||
'<div class="btnrow"><button class="btn is-primary" type="button">Review diff' +
|
||||
(a.pr ? ' #' + a.pr.number : '') + '</button>' +
|
||||
'<button class="btn" type="button">Mark reviewed</button></div>'
|
||||
);
|
||||
}
|
||||
if (a.state === 'working') {
|
||||
return (
|
||||
'<div class="reply"><input type="text" placeholder="Queue a message…" aria-label="Queue a message" />' +
|
||||
'<button class="btn is-danger" type="button">Interrupt</button></div>'
|
||||
);
|
||||
}
|
||||
return '<div class="btnrow"><button class="btn" type="button">Open terminal</button></div>';
|
||||
}
|
||||
|
||||
function renderPanel(a) {
|
||||
if (!a) {
|
||||
el.panel.classList.remove('is-open');
|
||||
el.panel.setAttribute('aria-hidden', 'true');
|
||||
return;
|
||||
}
|
||||
el.panel.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')');
|
||||
el.panel.innerHTML =
|
||||
'<button class="panel-close" type="button" aria-label="Close">×</button>' +
|
||||
'<div class="panel-body">' +
|
||||
'<h2 class="p-name">' + esc(a.shortName) + '</h2>' +
|
||||
'<div class="p-state"><i class="dot"></i>' + STATE_LABEL[a.state] +
|
||||
'<span class="live">· ' + liveness(a) + '</span></div>' +
|
||||
'<p class="p-path">' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '</p>' +
|
||||
'<div class="p-facts">' +
|
||||
'<div><div class="eyebrow">Started</div><div class="v">' + clock(a.startedAt) + '</div></div>' +
|
||||
'<div><div class="eyebrow">Elapsed</div><div class="v">' + F.ageLabel(a.startedAgo) + '</div></div>' +
|
||||
'<div><div class="eyebrow">Duration</div><div class="v">' + F.ageLabel(a.durationMs) + '</div></div>' +
|
||||
'</div>' +
|
||||
'<div><div class="eyebrow">Tool</div><div class="p-path">' + esc(a.detail || '—') + '</div></div>' +
|
||||
'<div class="p-tail"></div>' +
|
||||
'</div>' +
|
||||
'<div class="p-respond">' + respondHtml(a) + '</div>';
|
||||
|
||||
const st = streams.get(a.id) || makeStream(a);
|
||||
paintPanelTail(st);
|
||||
el.panel.classList.add('is-open');
|
||||
el.panel.setAttribute('aria-hidden', 'false');
|
||||
el.panel.querySelector('.panel-close').addEventListener('click', () => select(null));
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (selectedId && nodeOf.has(selectedId)) nodeOf.get(selectedId).classList.remove('is-selected');
|
||||
selectedId = id;
|
||||
if (id && nodeOf.has(id)) nodeOf.get(id).classList.add('is-selected');
|
||||
renderPanel(id ? byId.get(id) : null);
|
||||
}
|
||||
|
||||
// ---- search: dim misses, never reflow --------------------------------------
|
||||
|
||||
function applySearch(raw) {
|
||||
const q = raw.trim().toLowerCase();
|
||||
for (const a of F.agents) {
|
||||
const node = nodeOf.get(a.id);
|
||||
if (!node) continue;
|
||||
const hit =
|
||||
!q ||
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.worktree.toLowerCase().includes(q) ||
|
||||
a.displayProject.toLowerCase().includes(q) ||
|
||||
a.agentType.toLowerCase().includes(q) ||
|
||||
a.state.includes(q) ||
|
||||
STATE_LABEL[a.state].toLowerCase().includes(q);
|
||||
node.classList.toggle('is-dim', Boolean(q) && !hit);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- wiring ----------------------------------------------------------------
|
||||
|
||||
renderChrome();
|
||||
renderWall();
|
||||
layout();
|
||||
|
||||
el.wall.addEventListener('click', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (!node) return;
|
||||
select(node.dataset.agent === selectedId ? null : node.dataset.agent);
|
||||
});
|
||||
el.wall.addEventListener('mouseover', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node) showTip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.wall.addEventListener('mouseout', (e) => {
|
||||
if (e.target.closest('[data-agent]')) hideTip();
|
||||
});
|
||||
el.wall.addEventListener('focusin', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node) showTip(node, byId.get(node.dataset.agent));
|
||||
});
|
||||
el.wall.addEventListener('focusout', hideTip);
|
||||
el.wall.addEventListener('keydown', (e) => {
|
||||
const node = e.target.closest('[data-agent]');
|
||||
if (node && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
select(node.dataset.agent === selectedId ? null : node.dataset.agent);
|
||||
}
|
||||
});
|
||||
|
||||
el.search.addEventListener('input', () => applySearch(el.search.value));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key.toLowerCase() === 'k' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
el.search.focus();
|
||||
el.search.select();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (document.activeElement === el.search && el.search.value) {
|
||||
el.search.value = '';
|
||||
applySearch('');
|
||||
} else if (selectedId) {
|
||||
select(null);
|
||||
}
|
||||
hideTip();
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', layout);
|
||||
})();
|
||||
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 250 KiB |
@@ -0,0 +1,974 @@
|
||||
/* Fleet Tiles — one semantic-zoom canvas. Every agent is the same rounded tile in
|
||||
world units; only its INNER content swaps by altitude, so nothing counter-scales
|
||||
and text only exists in the band where the world scale makes it legible.
|
||||
Chrome is monochrome; hue is reserved for agent state. */
|
||||
|
||||
:root {
|
||||
/* World geometry — these are world units, which are CSS px before #world's transform. */
|
||||
--tile-w: 132px;
|
||||
--tile-h: 80px;
|
||||
--tile-r: 14px;
|
||||
--dim: 0.15;
|
||||
/* Slid left while the detail panel is open so the tools stay reachable. */
|
||||
--panel-inset: 0px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- top bar (hexmap language) --------------------------------------------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
height: 46px;
|
||||
flex: 0 0 46px;
|
||||
padding: 0 22px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tabs a {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tabs a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tabs a.is-active {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chip .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chip .n {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip .lbl {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.chip.is-loud .dot {
|
||||
opacity: 1;
|
||||
box-shadow: var(--glow-attention);
|
||||
}
|
||||
|
||||
.chip.is-loud .n {
|
||||
color: var(--state-attention);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chips .sep {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.topright {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 190px;
|
||||
border: 0;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.search::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accounting {
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- stage / world --------------------------------------------------------- */
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stage.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.stage.is-arranging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Pan and zoom mutate this transform and nothing else. */
|
||||
.world {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Scales with the world, so the grain reads as ground rather than as chrome. */
|
||||
.grid {
|
||||
position: absolute;
|
||||
left: -4000px;
|
||||
top: -4000px;
|
||||
width: 14000px;
|
||||
height: 10000px;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0);
|
||||
background-size: 44px 44px;
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Drawn over the tiles: a hairline at 0.5 opacity reads as kinship without
|
||||
burying the cards it connects, and cross-island edges must stay visible. */
|
||||
.lineage {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.lineage .edge {
|
||||
fill: none;
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1;
|
||||
opacity: 0.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.lineage .edge-end {
|
||||
fill: var(--muted-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ---- island (repo containment) --------------------------------------------- */
|
||||
|
||||
.island {
|
||||
position: absolute;
|
||||
border: 1px solid color-mix(in srgb, var(--hue) 26%, var(--border));
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--card) 40%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.island[data-worst='done'] {
|
||||
--hue: var(--state-idle);
|
||||
}
|
||||
|
||||
.island[data-worst='review'] {
|
||||
--hue: var(--state-done);
|
||||
}
|
||||
|
||||
.island[data-worst='working'] {
|
||||
--hue: var(--state-working);
|
||||
}
|
||||
|
||||
.island[data-worst='attention'],
|
||||
.island[data-worst='permission'] {
|
||||
--hue: var(--state-attention);
|
||||
}
|
||||
|
||||
.island[data-worst='failed'] {
|
||||
--hue: var(--state-blocked);
|
||||
}
|
||||
|
||||
.isl-head {
|
||||
position: absolute;
|
||||
left: 26px;
|
||||
top: 15px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.isl-head:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Big enough in world units to survive the far band without counter-scaling. */
|
||||
.isl-name {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.isl-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- worktree group -------------------------------------------------------- */
|
||||
|
||||
.group {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.wt-label {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 18px;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wt-label:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* The ellipsis has to live on the flex item, not on the flex container. */
|
||||
.wt-label > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wt-label .unread {
|
||||
flex: 0 0 auto;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.group.is-arranged .wt-label {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ---- tile ------------------------------------------------------------------ */
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
width: var(--tile-w);
|
||||
height: var(--tile-h);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--tile-r);
|
||||
background: color-mix(in srgb, var(--hue) 16%, var(--card));
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
z-index: 2;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.tile.is-dim {
|
||||
opacity: var(--dim);
|
||||
}
|
||||
|
||||
/* Liveness is its own channel: an exited process draws hollow. */
|
||||
.tile.is-dead {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.tile.is-dead .t-glyph {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 0.8px var(--hue);
|
||||
}
|
||||
|
||||
/* -- band swap: one tile, three insides -- */
|
||||
|
||||
.stage[data-zoom='far'] .t-card,
|
||||
.stage[data-zoom='far'] .t-term,
|
||||
.stage[data-zoom='mid'] .t-term,
|
||||
.stage[data-zoom='near'] .t-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stage[data-zoom='far'] .wt-label,
|
||||
.stage[data-zoom='far'] .isl-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Kiali's label rule: at mid only the groups that still owe you something are
|
||||
named; at near every group is named because there is room for all of them. */
|
||||
.stage[data-zoom='mid'] .group.is-quiet .wt-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* -- mid: the card -- */
|
||||
|
||||
.t-card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 8px 9px 6px;
|
||||
}
|
||||
|
||||
.t-name {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-right: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 17px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tile[data-bucket='needsYou'] .t-name {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.t-glyph {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 15px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.t-foot {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
right: 9px;
|
||||
bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.t-type {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
line-height: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.t-age {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.t-pr {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* -- near: the mini terminal -- */
|
||||
|
||||
/* --background, not --card: up close a tile is a screen, not a card. */
|
||||
.t-term {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 7px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
line-height: 12.5px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.t-term > div {
|
||||
flex: 0 0 12.5px;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.t-term .last,
|
||||
.p-tail .last {
|
||||
color: var(--foreground);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.t-term .last .txt,
|
||||
.p-tail .last .txt {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.cur {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 1px;
|
||||
color: var(--foreground);
|
||||
animation: cursor-blink 1.05s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The line that needs a human is the loudest text in the field. */
|
||||
.tile[data-bucket='needsYou'] .t-term .last {
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
/* -- per state -- */
|
||||
|
||||
.tile[data-state='done'] {
|
||||
--hue: var(--state-idle);
|
||||
background: color-mix(in srgb, var(--card) 55%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.tile[data-state='done'] .t-name,
|
||||
.tile[data-state='done'] .t-term {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.tile[data-state='review'] {
|
||||
--hue: var(--state-done);
|
||||
background: color-mix(in srgb, var(--state-done) 6%, var(--card));
|
||||
border-color: color-mix(in srgb, var(--state-done) 40%, var(--border));
|
||||
animation: pulse-review var(--pulse-slow) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tile[data-state='review'] .t-glyph {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tile[data-state='working'] {
|
||||
--hue: var(--state-working);
|
||||
background: color-mix(in srgb, var(--state-working) 6%, var(--card));
|
||||
border-color: color-mix(in srgb, var(--state-working) 24%, var(--border));
|
||||
}
|
||||
|
||||
.tile[data-state='working'] .t-glyph {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.tile[data-state='attention'],
|
||||
.tile[data-state='permission'] {
|
||||
--hue: var(--state-attention);
|
||||
border-color: var(--state-attention);
|
||||
box-shadow: var(--glow-attention);
|
||||
animation: blink-border-attention var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.tile[data-state='failed'] {
|
||||
--hue: var(--state-blocked);
|
||||
border-color: var(--state-blocked);
|
||||
box-shadow: 0 0 7px color-mix(in srgb, var(--state-blocked) 60%, transparent);
|
||||
animation: blink-border-blocked var(--blink-fast) steps(1, end) infinite;
|
||||
}
|
||||
|
||||
/* -- hover / selection -- */
|
||||
|
||||
.tile:hover,
|
||||
.tile:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--ring) 60%, var(--border));
|
||||
}
|
||||
|
||||
.tile.is-selected {
|
||||
z-index: 3;
|
||||
outline: 2.5px solid var(--ring);
|
||||
outline-offset: 1.5px;
|
||||
}
|
||||
|
||||
/* ---- viewport tools -------------------------------------------------------- */
|
||||
|
||||
.viewtools {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 34px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vt {
|
||||
padding: 4px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--popover) 82%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vt:hover {
|
||||
color: var(--foreground);
|
||||
border-color: var(--input);
|
||||
}
|
||||
|
||||
.vt:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- minimap --------------------------------------------------------------- */
|
||||
|
||||
.minimap {
|
||||
position: absolute;
|
||||
right: calc(20px + var(--panel-inset));
|
||||
bottom: 16px;
|
||||
z-index: 20;
|
||||
width: 200px;
|
||||
height: 130px;
|
||||
padding: 6px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-floating);
|
||||
cursor: crosshair;
|
||||
transition: right 190ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
/* Clipped: when the viewport is wider than the fleet, the view rect is meant to
|
||||
fill the minimap rather than spill over its frame. */
|
||||
.minimap svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mm-island {
|
||||
fill: none;
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.mm-dot {
|
||||
fill: var(--hue);
|
||||
}
|
||||
|
||||
.mm-view {
|
||||
fill: color-mix(in srgb, var(--ring) 10%, transparent);
|
||||
stroke: var(--ring);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
/* ---- empty state ----------------------------------------------------------- */
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 15;
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-floating);
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ---- footer whisper -------------------------------------------------------- */
|
||||
|
||||
.whisper {
|
||||
flex: 0 0 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 22px;
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.whisper .key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.whisper .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.whisper .sep {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ---- tooltip --------------------------------------------------------------- */
|
||||
|
||||
.tip {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
max-width: 300px;
|
||||
padding: 9px 11px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-floating);
|
||||
pointer-events: none;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tip .t-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.tip .t-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.tip .t-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta {
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.tip .t-meta .mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- selection panel (hexmap language) ------------------------------------- */
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 46px;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
z-index: 50;
|
||||
width: 370px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-floating);
|
||||
transform: translateX(101%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 190ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
visibility 190ms;
|
||||
}
|
||||
|
||||
.panel.is-open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 22px 8px;
|
||||
}
|
||||
|
||||
.panel-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-close:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-name {
|
||||
margin: 0 30px 8px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.p-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
color: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--hue);
|
||||
}
|
||||
|
||||
.p-state .live {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.p-path {
|
||||
margin: 8px 0 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--muted-foreground);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.p-facts {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
margin: 16px 0 14px;
|
||||
}
|
||||
|
||||
.p-facts .v {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.p-tail {
|
||||
margin-top: 12px;
|
||||
padding: 9px 11px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.p-tail > div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.p-respond {
|
||||
padding: 14px 22px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.p-ask {
|
||||
margin: 0 0 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.p-cmd {
|
||||
margin: 0 0 11px;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.p-fail {
|
||||
margin: 0 0 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
|
||||
.opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
background: var(--secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opt:hover {
|
||||
border-color: var(--input);
|
||||
}
|
||||
|
||||
.opt .num {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.reply input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 7px 9px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.reply input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.reply input:focus {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.btnrow {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.btn.is-primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.btn.is-primary:hover {
|
||||
background: var(--foreground);
|
||||
}
|
||||
|
||||
.btn.is-danger:hover {
|
||||
border-color: color-mix(in srgb, var(--state-blocked) 55%, var(--border));
|
||||
color: var(--state-blocked);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Fleet — Tiles</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-tiles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<span class="brand">Fleet</span>
|
||||
<nav class="tabs" aria-label="Fleet views">
|
||||
<a href="./fleet-map.html">Map</a>
|
||||
<a href="./fleet-strips.html">Strips</a>
|
||||
<a href="./mission-control.html">Mission Control</a>
|
||||
<a href="./fleet-hexmap.html">Hexmap</a>
|
||||
<a href="./fleet-terminal-wall.html">Wall</a>
|
||||
<a href="./fleet-triage.html">Triage</a>
|
||||
<a href="./fleet-canvas.html">Canvas</a>
|
||||
<a class="is-active" href="./fleet-tiles.html" aria-current="page">Tiles</a>
|
||||
</nav>
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="topright">
|
||||
<input
|
||||
id="search"
|
||||
class="search"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filter the fleet"
|
||||
/>
|
||||
<span class="accounting" id="accounting"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stage" id="stage" data-zoom="mid">
|
||||
<div class="world" id="world">
|
||||
<div class="grid" aria-hidden="true"></div>
|
||||
<svg class="lineage" id="lineage" aria-hidden="true"></svg>
|
||||
</div>
|
||||
|
||||
<p class="empty" id="empty" hidden></p>
|
||||
|
||||
<div class="viewtools">
|
||||
<button class="vt" id="fit" type="button">Fit</button>
|
||||
<button class="vt" id="resetlayout" type="button">Reset layout</button>
|
||||
</div>
|
||||
|
||||
<div class="minimap" id="minimap" role="group" aria-label="Minimap — click to recentre">
|
||||
<svg id="mmsvg" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||
<g id="mmislands"></g>
|
||||
<g id="mmdots"></g>
|
||||
<rect class="mm-view" id="mmview" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="whisper" id="whisper"></footer>
|
||||
</main>
|
||||
|
||||
<aside class="panel" id="panel" aria-label="Agent detail" aria-hidden="true"></aside>
|
||||
<div class="tip" id="tip" role="tooltip" hidden></div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-tiles.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 307 KiB |
@@ -0,0 +1,312 @@
|
||||
/* Fleet · Triage — desktop-native inbox. Compact list of what needs you on the
|
||||
left, full context and actions for the selected item on the right. Calm comes
|
||||
from spacing and restraint, not from phone furniture. */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---------- left: the list ---------- */
|
||||
|
||||
.list {
|
||||
flex: 0 0 420px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 20px 20px 14px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.list-counts {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.sections {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: 0 10px 10px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--accent) transparent;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 16px 10px 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.section-label .n {
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
min-height: 34px;
|
||||
}
|
||||
.item:hover {
|
||||
background: color-mix(in srgb, var(--secondary) 60%, transparent);
|
||||
}
|
||||
.item.is-selected {
|
||||
background: var(--secondary);
|
||||
}
|
||||
.item-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.item-title code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.item-meta {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.dot-attention { background: var(--state-attention); }
|
||||
.dot-blocked { background: var(--state-blocked); }
|
||||
.dot-working { background: color-mix(in srgb, var(--state-working) 65%, transparent); }
|
||||
.dot-review { background: color-mix(in srgb, var(--state-done) 75%, transparent); }
|
||||
.dot-done { background: var(--state-idle); }
|
||||
|
||||
.item-more {
|
||||
padding: 6px 10px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
.item-more:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
padding: 12px 20px 16px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted-foreground);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.list-foot a {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.list-foot a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ---------- right: the selected item ---------- */
|
||||
|
||||
.detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 44px 56px 48px;
|
||||
}
|
||||
.detail-inner {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.detail-kind {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.detail h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.012em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.detail h1 code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
background: var(--secondary);
|
||||
border-radius: 7px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
.detail-sub {
|
||||
margin: 10px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted-foreground);
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--foreground);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.reply {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.reply input {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.reply input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.reply input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
/* Sections inside the detail pane */
|
||||
.block {
|
||||
margin-top: 32px;
|
||||
}
|
||||
.block-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.agent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 44px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.agent-row:last-child {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: var(--secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.agent-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.agent-meta {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tail {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in srgb, var(--card) 70%, var(--background));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--muted-foreground);
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tail .now {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.resolved-note {
|
||||
margin-top: 20px;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fleet — Triage</title>
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./fleet-triage.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="list">
|
||||
<header class="list-head">
|
||||
<span class="wordmark">Fleet</span>
|
||||
<span class="list-counts" id="list-counts"></span>
|
||||
</header>
|
||||
<div class="sections" id="sections"></div>
|
||||
<footer class="list-foot" id="list-foot"></footer>
|
||||
</aside>
|
||||
<main class="detail" id="detail"></main>
|
||||
</div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./fleet-triage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,232 @@
|
||||
/* Fleet · Triage — list + detail from window.FLEET. */
|
||||
(function () {
|
||||
'use strict';
|
||||
const F = window.FLEET;
|
||||
const attention = F.agents.filter((a) => a.state === 'attention');
|
||||
const failed = F.agents.filter((a) => a.state === 'failed');
|
||||
const review = F.agents
|
||||
.filter((a) => a.state === 'review')
|
||||
.sort((x, y) => (y.completedAt || 0) - (x.completedAt || 0));
|
||||
const working = F.buckets.working;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- item list -------------------------------------------------------------
|
||||
const items = [];
|
||||
F.decisions.forEach((d) => items.push({ type: 'decision', d }));
|
||||
attention.forEach((a) => items.push({ type: 'question', a }));
|
||||
items.push({ type: 'failures' });
|
||||
working.slice(0, 4).forEach((a) => items.push({ type: 'working', a }));
|
||||
review.slice(0, 4).forEach((a) => items.push({ type: 'review', a }));
|
||||
|
||||
const needsYouCount = F.decisions.length + attention.length + 1;
|
||||
document.getElementById('list-counts').textContent = needsYouCount + ' need you';
|
||||
|
||||
const sections = document.getElementById('sections');
|
||||
const rows = new Map();
|
||||
let selected = null;
|
||||
|
||||
function addLabel(text, n) {
|
||||
const l = el('div', 'section-label', text);
|
||||
l.append(el('span', 'n', String(n)));
|
||||
sections.append(l);
|
||||
}
|
||||
function addItem(item, dotCls, title, meta, titleIsCode) {
|
||||
const r = el('div', 'item');
|
||||
const t = el('span', 'item-title');
|
||||
if (titleIsCode) {
|
||||
t.append('Allow ', Object.assign(el('code'), { textContent: title }));
|
||||
} else {
|
||||
t.textContent = title;
|
||||
}
|
||||
r.append(el('i', 'dot ' + dotCls), t);
|
||||
if (meta) r.append(el('span', 'item-meta', meta));
|
||||
r.addEventListener('click', () => select(item));
|
||||
sections.append(r);
|
||||
rows.set(item, r);
|
||||
}
|
||||
function addMore(n) {
|
||||
sections.append(el('div', 'item-more', n + ' more'));
|
||||
}
|
||||
|
||||
addLabel('Needs you', needsYouCount);
|
||||
items.forEach((it) => {
|
||||
if (it.type === 'decision') {
|
||||
addItem(it, 'dot-attention', it.d.command, it.d.agents.length + ' agents', true);
|
||||
} else if (it.type === 'question') {
|
||||
addItem(it, 'dot-attention', it.a.question.text, F.ageLabel(it.a.startedAgo));
|
||||
} else if (it.type === 'failures') {
|
||||
addItem(it, 'dot-blocked', failed.length + ' agents stopped on errors', null);
|
||||
}
|
||||
});
|
||||
|
||||
addLabel('Working', working.length);
|
||||
items.filter((i) => i.type === 'working').forEach((it) => {
|
||||
addItem(it, 'dot-working', it.a.shortName, it.a.loopSleeping ? 'sleeping' : F.ageLabel(it.a.startedAgo));
|
||||
});
|
||||
addMore(working.length - 4);
|
||||
|
||||
addLabel('Ready to review', review.length);
|
||||
items.filter((i) => i.type === 'review').forEach((it) => {
|
||||
addItem(it, 'dot-review', it.a.shortName, (it.a.pr ? '#' + it.a.pr.number : F.ageLabel(it.a.durationMs)));
|
||||
});
|
||||
addMore(review.length - 4);
|
||||
|
||||
addLabel('Done', F.buckets.done.length);
|
||||
|
||||
// ---- footer ----------------------------------------------------------------
|
||||
const foot = document.getElementById('list-foot');
|
||||
const t = new Date(F.now);
|
||||
foot.append(
|
||||
el('span', null, F.counts.fleetTotal + ' agents · ' + F.counts.fleetWorktrees + ' worktrees'),
|
||||
(() => {
|
||||
const links = el('span');
|
||||
[['Inbox', 'fleet-inbox'], ['Hexmap', 'fleet-hexmap'], ['Wall', 'fleet-terminal-wall']].forEach(([label, f]) => {
|
||||
const a = el('a', null, label);
|
||||
a.href = './' + f + '.html';
|
||||
links.append(a);
|
||||
});
|
||||
return links;
|
||||
})(),
|
||||
);
|
||||
|
||||
// ---- detail pane -----------------------------------------------------------
|
||||
const detail = document.getElementById('detail');
|
||||
|
||||
function agentRow(a, meta) {
|
||||
const r = el('div', 'agent-row');
|
||||
r.append(
|
||||
el('span', 'avatar', a.agentType[0].toUpperCase()),
|
||||
el('span', 'agent-name', a.shortName),
|
||||
el('span', 'agent-meta', meta),
|
||||
);
|
||||
return r;
|
||||
}
|
||||
function tailBlock(a, label) {
|
||||
const b = el('div', 'block');
|
||||
b.append(el('p', 'block-label', label || 'Latest output'));
|
||||
const lines = F.fakeTail(a).slice(-7);
|
||||
const pre = el('pre', 'tail');
|
||||
lines.forEach((ln, i) => {
|
||||
const s = el('span', i === lines.length - 1 ? 'now' : null, ln + '\n');
|
||||
pre.append(s);
|
||||
});
|
||||
b.append(pre);
|
||||
return b;
|
||||
}
|
||||
function actionsRow(defs) {
|
||||
const row = el('div', 'actions');
|
||||
defs.forEach(([label, cls, fn]) => {
|
||||
const b = el('button', 'btn' + (cls ? ' ' + cls : ''), label);
|
||||
if (fn) b.addEventListener('click', fn);
|
||||
row.append(b);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
function replyRow(placeholder) {
|
||||
const row = el('div', 'reply');
|
||||
const input = Object.assign(el('input'), { placeholder });
|
||||
row.append(input, (() => {
|
||||
const b = el('button', 'btn', 'Send');
|
||||
return b;
|
||||
})());
|
||||
return row;
|
||||
}
|
||||
|
||||
function render(item) {
|
||||
detail.replaceChildren();
|
||||
const inner = el('div', 'detail-inner');
|
||||
detail.append(inner);
|
||||
|
||||
if (item.type === 'decision') {
|
||||
const d = item.d;
|
||||
const projects = [...new Set(d.agents.map((a) => a.displayProject))];
|
||||
inner.append(el('p', 'detail-kind', 'Permission request · ' + d.agents.length + ' agents · ' + projects.join(', ')));
|
||||
const h = el('h1');
|
||||
h.append('Allow ', Object.assign(el('code'), { textContent: d.command }));
|
||||
inner.append(h);
|
||||
inner.append(el('p', 'detail-sub', d.detail + '. ' + d.agents.length + ' agents are paused on this command; one decision resumes all of them.'));
|
||||
inner.append(actionsRow([
|
||||
['Allow', 'btn-primary', () => resolved(inner, d.agents.length + ' agents resuming.')],
|
||||
['Allow for ' + projects[0] + ' only', null, null],
|
||||
['Deny', 'btn-ghost', null],
|
||||
]));
|
||||
const wb = el('div', 'block');
|
||||
wb.append(el('p', 'block-label', 'Waiting'));
|
||||
d.agents.forEach((a) => wb.append(agentRow(a, a.worktree + ' · ' + F.ageLabel(a.startedAgo))));
|
||||
inner.append(wb, tailBlock(d.agents[0]));
|
||||
} else if (item.type === 'question') {
|
||||
const a = item.a;
|
||||
inner.append(el('p', 'detail-kind', 'Question · ' + a.agentType + ' · ' + a.worktree));
|
||||
inner.append(el('h1', null, a.question.text));
|
||||
inner.append(el('p', 'detail-sub', a.shortName));
|
||||
inner.append(actionsRow(a.question.options.map((opt, i) => [
|
||||
opt,
|
||||
i === 0 ? 'btn-primary' : null,
|
||||
() => resolved(inner, 'Answered — ' + opt),
|
||||
])));
|
||||
inner.append(replyRow('Or answer in your own words…'), tailBlock(a));
|
||||
} else if (item.type === 'failures') {
|
||||
inner.append(el('p', 'detail-kind', 'Stopped · ' + failed.length + ' agents'));
|
||||
inner.append(el('h1', null, failed.length + ' agents stopped on errors'));
|
||||
inner.append(actionsRow([
|
||||
['Retry both', 'btn-primary', null],
|
||||
['Open terminal', null, null],
|
||||
]));
|
||||
failed.forEach((a) => {
|
||||
const b = el('div', 'block');
|
||||
b.append(el('p', 'block-label', a.worktree));
|
||||
b.append(agentRow(a, F.ageLabel(a.startedAgo)));
|
||||
b.append(tailBlock(a, 'Error').lastChild);
|
||||
inner.append(b);
|
||||
});
|
||||
} else if (item.type === 'working') {
|
||||
const a = item.a;
|
||||
inner.append(el('p', 'detail-kind', 'Working · ' + a.agentType + ' · ' + a.worktree));
|
||||
inner.append(el('h1', null, a.shortName));
|
||||
inner.append(el('p', 'detail-sub', (a.detail || 'Working') + ' · running ' + F.ageLabel(a.startedAgo)));
|
||||
inner.append(replyRow('Queue a message — delivered when it pauses…'));
|
||||
inner.append(actionsRow([['Interrupt', 'btn-ghost', null]]), tailBlock(a, 'Live output'));
|
||||
} else if (item.type === 'review') {
|
||||
const a = item.a;
|
||||
inner.append(el('p', 'detail-kind', 'Ready to review · ' + a.agentType + ' · ' + a.worktree));
|
||||
inner.append(el('h1', null, a.shortName));
|
||||
inner.append(el('p', 'detail-sub', 'Finished · ' + F.ageLabel(a.durationMs) + ' run' + (a.pr ? ' · PR #' + a.pr.number : '')));
|
||||
inner.append(actionsRow([
|
||||
['Review diff', 'btn-primary', null],
|
||||
['Mark reviewed', null, () => resolved(inner, 'Reviewed. It will leave the list.')],
|
||||
['Reopen', 'btn-ghost', null],
|
||||
]));
|
||||
inner.append(tailBlock(a, 'Final output'));
|
||||
}
|
||||
}
|
||||
|
||||
function resolved(inner, note) {
|
||||
inner.querySelector('.actions')?.remove();
|
||||
inner.querySelector('.reply')?.remove();
|
||||
inner.insertBefore(el('p', 'resolved-note', note), inner.querySelector('.block'));
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
if (selected) rows.get(selected)?.classList.remove('is-selected');
|
||||
selected = item;
|
||||
rows.get(item)?.classList.add('is-selected');
|
||||
render(item);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
const i = items.indexOf(selected);
|
||||
const next = items[i + (e.key === 'ArrowDown' ? 1 : -1)];
|
||||
if (next) select(next);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
select(items[0]);
|
||||
})();
|
||||
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mission Control — Orca agent fleet</title>
|
||||
<link rel="stylesheet" href="./control-room-tokens.css" />
|
||||
<link rel="stylesheet" href="./mission-control.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<h1 class="brand-title">Mission Control</h1>
|
||||
<nav class="tabs" aria-label="Fleet views">
|
||||
<a class="tab" href="./fleet-map.html">Map</a>
|
||||
<a class="tab" href="./fleet-strips.html">Strips</a>
|
||||
<span class="tab is-active" aria-current="page">Mission Control</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="dials" id="dials"></div>
|
||||
|
||||
<div class="topbar-end">
|
||||
<span class="accounting" id="accounting"></span>
|
||||
<label class="search">
|
||||
<span class="search-glyph" aria-hidden="true">⌕</span>
|
||||
<input
|
||||
id="search"
|
||||
type="search"
|
||||
placeholder="Search worktrees, agents…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<kbd id="search-kbd"></kbd>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="board">
|
||||
<section class="map" id="map" aria-label="Containment map">
|
||||
<div class="bands" id="bands"></div>
|
||||
<svg class="lineage" id="lineage" aria-hidden="true"></svg>
|
||||
<p class="map-empty" id="map-empty" hidden></p>
|
||||
</section>
|
||||
|
||||
<aside class="rail" id="rail" aria-label="Intervention rail"></aside>
|
||||
</main>
|
||||
|
||||
<footer class="statusbar">
|
||||
<div class="legend" id="legend-shape"></div>
|
||||
<div class="legend legend-motion">
|
||||
<span><i class="swatch swatch-blink"></i>fast = needs you</span>
|
||||
<span><i class="swatch swatch-pulse"></i>slow = to review</span>
|
||||
<span><i class="swatch swatch-still"></i>still = settled</span>
|
||||
</div>
|
||||
<div class="legend legend-keys" id="legend-keys"></div>
|
||||
<div class="capture" id="capture"></div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="tooltip" id="tooltip" role="tooltip" hidden></div>
|
||||
|
||||
<script src="../agent-viewer-alternatives/actual-agent-activity.js"></script>
|
||||
<script src="./fleet-state-synthesis.js"></script>
|
||||
<script src="./mission-control.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,933 @@
|
||||
/* Mission Control — the containment map and the intervention rail are one surface:
|
||||
selecting on the map highlights the decision that would unblock it, and acting in the rail
|
||||
moves the glyphs on the map. Every number, name and age comes from window.FLEET. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FLEET = window.FLEET;
|
||||
if (!FLEET) throw new Error('fleet-state-synthesis.js must be loaded first');
|
||||
|
||||
const IS_MAC = navigator.userAgent.includes('Mac');
|
||||
const MOD = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
const PAUSE_MS = 8000;
|
||||
|
||||
const STATE_LABEL = {
|
||||
attention: 'question waiting',
|
||||
permission: 'permission waiting',
|
||||
failed: 'failed',
|
||||
working: 'working',
|
||||
review: 'awaiting review',
|
||||
done: 'done',
|
||||
};
|
||||
const LEGEND_ORDER = ['attention', 'permission', 'failed', 'working', 'review', 'done'];
|
||||
const LEGEND_LABEL = {
|
||||
attention: 'question',
|
||||
permission: 'permission',
|
||||
failed: 'failed',
|
||||
working: 'working',
|
||||
review: 'review',
|
||||
done: 'done',
|
||||
};
|
||||
const SCOPES = [
|
||||
{ id: 'once', label: 'This once' },
|
||||
{ id: 'project', label: 'This project' },
|
||||
{ id: 'always', label: 'Always' },
|
||||
];
|
||||
|
||||
const byId = new Map(FLEET.agents.map((a) => [a.id, a]));
|
||||
const childrenOf = new Map();
|
||||
for (const a of FLEET.agents) {
|
||||
if (a.parent && byId.has(a.parent)) {
|
||||
if (!childrenOf.has(a.parent)) childrenOf.set(a.parent, []);
|
||||
childrenOf.get(a.parent).push(a);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mutable view state ----------------------------------------------------
|
||||
const resolved = new Set(); // agents the operator has already acted on
|
||||
const scopeByDecision = new Map(FLEET.decisions.map((d) => [d.key, 'once']));
|
||||
let selectedId = null;
|
||||
let expandedDecision = FLEET.decisions.length ? FLEET.decisions[0].key : null;
|
||||
let query = '';
|
||||
let peek = false;
|
||||
let lastInteraction = Date.now();
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const dom = {
|
||||
dials: $('#dials'),
|
||||
accounting: $('#accounting'),
|
||||
bands: $('#bands'),
|
||||
map: $('#map'),
|
||||
mapEmpty: $('#map-empty'),
|
||||
lineage: $('#lineage'),
|
||||
rail: $('#rail'),
|
||||
tooltip: $('#tooltip'),
|
||||
search: $('#search'),
|
||||
searchKbd: $('#search-kbd'),
|
||||
legendShape: $('#legend-shape'),
|
||||
legendKeys: $('#legend-keys'),
|
||||
capture: $('#capture'),
|
||||
};
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
/* An agent the operator has answered is no longer waiting on them: a resolved needs-you
|
||||
agent goes back to work, a reviewed run goes quiet. Everything else reads from FLEET. */
|
||||
function effState(a) {
|
||||
if (!resolved.has(a.id)) return a.state;
|
||||
return a.bucket === 'review' ? 'done' : 'working';
|
||||
}
|
||||
const effBucket = (a) => {
|
||||
const s = effState(a);
|
||||
return s === 'review' ? 'review' : s === 'done' ? 'done' : s === 'working' ? 'working' : 'needsYou';
|
||||
};
|
||||
const isUnacked = (a) => effBucket(a) === 'needsYou';
|
||||
|
||||
function counts() {
|
||||
const c = { needsYou: 0, working: 0, review: 0, done: 0 };
|
||||
for (const a of FLEET.agents) c[effBucket(a)]++;
|
||||
return c;
|
||||
}
|
||||
|
||||
const liveState = (a) =>
|
||||
a.loopSleeping ? 'loop sleeping' : a.alive ? 'alive — replies now' : 'exited — restarts on reply';
|
||||
|
||||
function motionFor(state) {
|
||||
if (state === 'attention' || state === 'permission' || state === 'failed') return 'blink';
|
||||
if (state === 'review') return 'pulse';
|
||||
return 'still';
|
||||
}
|
||||
|
||||
const openList = (state) =>
|
||||
FLEET.agents.filter((a) => a.state === state && !resolved.has(a.id));
|
||||
const openDecisions = () =>
|
||||
FLEET.decisions
|
||||
.map((d) => ({ decision: d, agents: d.agents.filter((a) => !resolved.has(a.id)) }))
|
||||
.filter((d) => d.agents.length > 0);
|
||||
|
||||
// ---- glyphs ----------------------------------------------------------------
|
||||
|
||||
function glyph(a, opts) {
|
||||
const options = opts || {};
|
||||
const state = effState(a);
|
||||
const node = el(options.static ? 'span' : 'button', 'glyph');
|
||||
if (!options.static) node.type = 'button';
|
||||
node.dataset.state = state;
|
||||
node.dataset.live = a.loopSleeping ? 'sleeping' : a.alive ? 'alive' : 'exited';
|
||||
node.dataset.motion = options.static ? 'still' : motionFor(state);
|
||||
node.dataset.agent = a.id;
|
||||
node.setAttribute('aria-label', a.shortName + ' — ' + STATE_LABEL[state] + ', ' + liveState(a));
|
||||
|
||||
if (a.loopSleeping) node.append(el('i', 'g-crescent'));
|
||||
else if (state === 'working') node.append(el('i', 'g-ring'));
|
||||
else node.textContent = FLEET.stateGlyph(state);
|
||||
|
||||
if (!options.static) {
|
||||
node.addEventListener('mouseenter', (e) => showTooltip(a, e));
|
||||
node.addEventListener('mousemove', (e) => moveTooltip(e));
|
||||
node.addEventListener('mouseleave', hideTooltip);
|
||||
node.addEventListener('click', () => select(a.id));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function sampleGlyph(state) {
|
||||
const node = el('span', 'glyph');
|
||||
node.dataset.state = state;
|
||||
node.dataset.live = 'alive';
|
||||
node.dataset.motion = 'still';
|
||||
if (state === 'working') node.append(el('i', 'g-ring'));
|
||||
else node.textContent = FLEET.stateGlyph(state);
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---- top bar ---------------------------------------------------------------
|
||||
|
||||
function renderDials() {
|
||||
const c = counts();
|
||||
const spec = [
|
||||
{ label: 'Needs you', value: c.needsYou, tone: 'attention', live: c.needsYou > 0 },
|
||||
{ label: 'Working', value: c.working, tone: 'plain', live: false },
|
||||
{ label: 'To review', value: c.review, tone: 'review', live: false },
|
||||
{ label: 'Done', value: c.done, tone: 'quiet', live: false },
|
||||
];
|
||||
dom.dials.replaceChildren(
|
||||
...spec.map((s) => {
|
||||
const d = el('div', 'dial');
|
||||
d.dataset.tone = s.tone;
|
||||
d.dataset.live = String(s.live);
|
||||
d.append(el('div', 'dial-value', String(s.value)), el('div', 'eyebrow dial-label', s.label));
|
||||
return d;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function renderAccounting() {
|
||||
if (query) {
|
||||
const hits = FLEET.byWorktree.filter(matchesQuery).length;
|
||||
dom.accounting.textContent =
|
||||
'⌕ ' + hits + ' of ' + FLEET.byWorktree.length + ' worktrees match';
|
||||
return;
|
||||
}
|
||||
dom.accounting.textContent =
|
||||
FLEET.counts.total +
|
||||
' of ' +
|
||||
FLEET.counts.fleetTotal +
|
||||
' agents · ' +
|
||||
FLEET.byWorktree.length +
|
||||
' of ' +
|
||||
FLEET.counts.fleetWorktrees +
|
||||
' worktrees';
|
||||
}
|
||||
|
||||
// ---- containment map -------------------------------------------------------
|
||||
|
||||
function renderBands() {
|
||||
dom.bands.replaceChildren(
|
||||
...FLEET.projects.map((project) => {
|
||||
const worst = worstOf(project.agents);
|
||||
const band = el('section', 'band');
|
||||
band.style.setProperty('--band-tint', 'var(' + FLEET.stateColorVar(worst) + ')');
|
||||
band.dataset.grow = String(project.name === 'orca');
|
||||
band.dataset.annunciate = String(project.agents.some(isUnacked));
|
||||
|
||||
const head = el('div', 'band-head');
|
||||
head.append(
|
||||
el(
|
||||
'span',
|
||||
'eyebrow',
|
||||
project.name.toUpperCase() +
|
||||
' · ' +
|
||||
project.worktrees.length +
|
||||
' worktrees · ' +
|
||||
project.agents.length +
|
||||
' agents'
|
||||
)
|
||||
);
|
||||
const flag = el('span', 'band-worst');
|
||||
flag.append(sampleGlyph(worst), el('span', null, 'worst: ' + STATE_LABEL[worst]));
|
||||
head.append(flag);
|
||||
|
||||
const cells = el('div', 'cells');
|
||||
if (project.worktrees.length === 0) {
|
||||
cells.append(el('p', 'rail-empty', 'No worktrees in this project.'));
|
||||
} else {
|
||||
// FLEET.byWorktree order, never re-sorted by state — a cell keeps its slot so the
|
||||
// operator can learn the map's geography.
|
||||
for (const wt of project.worktrees) cells.append(cell(wt));
|
||||
}
|
||||
cells.addEventListener('scroll', drawLineage, { passive: true });
|
||||
band.append(head, cells);
|
||||
return band;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function worstOf(list) {
|
||||
let best = 'done';
|
||||
for (const a of list) {
|
||||
const s = effState(a);
|
||||
if (FLEET.severityRank(s) < FLEET.severityRank(best)) best = s;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function cell(wt) {
|
||||
const node = el('div', 'cell');
|
||||
node.dataset.worktree = wt.project + '/' + wt.worktree;
|
||||
const head = el('div', 'cell-head');
|
||||
head.append(el('span', 'cell-name', wt.worktree));
|
||||
if (wt.unread) head.append(el('span', 'cell-unread'));
|
||||
head.append(el('span', 'cell-count', String(wt.agents.length)));
|
||||
const glyphs = el('div', 'cell-glyphs');
|
||||
for (const a of wt.agents) glyphs.append(glyph(a));
|
||||
node.append(head, glyphs);
|
||||
return node;
|
||||
}
|
||||
|
||||
function matchesQuery(wt) {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
if (wt.worktree.toLowerCase().includes(q) || wt.project.toLowerCase().includes(q)) return true;
|
||||
return wt.agents.some(
|
||||
(a) =>
|
||||
a.shortName.toLowerCase().includes(q) ||
|
||||
(a.detail || '').toLowerCase().includes(q) ||
|
||||
effState(a).includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
let hits = 0;
|
||||
for (const wt of FLEET.byWorktree) {
|
||||
const node = dom.bands.querySelector(
|
||||
'.cell[data-worktree="' + cssEscape(wt.project + '/' + wt.worktree) + '"]'
|
||||
);
|
||||
if (!node) continue;
|
||||
const hit = matchesQuery(wt);
|
||||
if (hit) hits++;
|
||||
node.classList.toggle('is-dimmed', Boolean(query) && !hit);
|
||||
node.classList.toggle('is-hit', Boolean(query) && hit);
|
||||
}
|
||||
const blank = Boolean(query) && hits === 0;
|
||||
dom.mapEmpty.hidden = !blank;
|
||||
if (blank) dom.mapEmpty.textContent = 'No worktree, agent or state matches “' + query + '”.';
|
||||
renderAccounting();
|
||||
}
|
||||
|
||||
const cssEscape = (value) => value.replace(/["\\]/g, '\\$&');
|
||||
|
||||
// ---- lineage overlay -------------------------------------------------------
|
||||
|
||||
function mapGlyph(id) {
|
||||
return dom.bands.querySelector('.glyph[data-agent="' + cssEscape(id) + '"]');
|
||||
}
|
||||
|
||||
function drawLineage() {
|
||||
dom.lineage.replaceChildren();
|
||||
for (const node of dom.bands.querySelectorAll('.glyph.is-linked')) {
|
||||
node.classList.remove('is-linked');
|
||||
}
|
||||
if (!selectedId) return;
|
||||
const selected = byId.get(selectedId);
|
||||
if (!selected) return;
|
||||
|
||||
const box = dom.map.getBoundingClientRect();
|
||||
dom.lineage.setAttribute('width', String(box.width));
|
||||
dom.lineage.setAttribute('height', String(box.height));
|
||||
dom.lineage.setAttribute('viewBox', '0 0 ' + box.width + ' ' + box.height);
|
||||
|
||||
const from = center(selectedId, box);
|
||||
if (!from) return;
|
||||
const kin = [];
|
||||
if (selected.parent && byId.has(selected.parent)) kin.push(byId.get(selected.parent));
|
||||
kin.push(...(childrenOf.get(selectedId) || []));
|
||||
|
||||
for (const relative of kin) {
|
||||
const to = center(relative.id, box);
|
||||
if (!to) continue; // scrolled out of its band — draw nothing rather than a lying edge
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', sweep(from, to));
|
||||
dom.lineage.append(path, dot(to));
|
||||
const node = mapGlyph(relative.id);
|
||||
if (node) node.classList.add('is-linked');
|
||||
}
|
||||
if (dom.lineage.childNodes.length) dom.lineage.append(dot(from));
|
||||
}
|
||||
|
||||
/* A lateral sweep rather than a straight run, so the edge reads as a relationship laid over
|
||||
the grid instead of a border between cells. */
|
||||
function sweep(from, to) {
|
||||
const dx = to.x - from.x;
|
||||
const dy = to.y - from.y;
|
||||
const sign = dx >= 0 ? 1 : -1;
|
||||
const lateral = Math.abs(dx) < 30 ? 46 : Math.max(30, Math.min(110, Math.abs(dx) / 2));
|
||||
const c1 = { x: from.x + sign * lateral, y: from.y + dy * 0.12 };
|
||||
const c2 = { x: to.x - sign * lateral, y: to.y - dy * 0.12 };
|
||||
return (
|
||||
'M ' + from.x + ' ' + from.y +
|
||||
' C ' + c1.x + ' ' + c1.y + ', ' + c2.x + ' ' + c2.y + ', ' + to.x + ' ' + to.y
|
||||
);
|
||||
}
|
||||
|
||||
function dot(point) {
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', String(point.x));
|
||||
circle.setAttribute('cy', String(point.y));
|
||||
circle.setAttribute('r', '1.6');
|
||||
return circle;
|
||||
}
|
||||
|
||||
/* Returns null when the glyph is clipped by its band's own scroller: getBoundingClientRect
|
||||
still reports a laid-out position for a scrolled-away cell, and an edge drawn to one would
|
||||
point at nothing. */
|
||||
function center(id, box) {
|
||||
const node = mapGlyph(id);
|
||||
if (!node) return null;
|
||||
const r = node.getBoundingClientRect();
|
||||
const clip = node.closest('.cells');
|
||||
if (clip) {
|
||||
const c = clip.getBoundingClientRect();
|
||||
if (r.bottom < c.top + 2 || r.top > c.bottom - 2) return null;
|
||||
}
|
||||
const point = { x: r.left + r.width / 2 - box.left, y: r.top + r.height / 2 - box.top };
|
||||
if (point.y < 0 || point.y > box.height || point.x < 0 || point.x > box.width) return null;
|
||||
return point;
|
||||
}
|
||||
|
||||
// ---- tooltip ---------------------------------------------------------------
|
||||
|
||||
function tooltipLine(label, value) {
|
||||
const row = el('div', 'tooltip-line');
|
||||
row.append(el('span', 'eyebrow', label), el('span', null, value));
|
||||
return row;
|
||||
}
|
||||
|
||||
function showTooltip(a, event) {
|
||||
const state = effState(a);
|
||||
dom.tooltip.replaceChildren(
|
||||
el('div', 'tooltip-name', a.shortName),
|
||||
tooltipLine('state', STATE_LABEL[state] + ' · ' + liveState(a)),
|
||||
tooltipLine('where', a.displayProject + ' / ' + a.worktree),
|
||||
tooltipLine('age', 'started ' + FLEET.ageLabel(a.startedAgo) + ' ago'),
|
||||
tooltipLine('run', FLEET.ageLabel(a.durationMs) + (a.bucket === 'done' || a.bucket === 'review' ? ' (final)' : ' so far')),
|
||||
tooltipLine(a.bucket === 'working' || a.bucket === 'needsYou' ? 'tool' : 'last', a.detail || '—')
|
||||
);
|
||||
if (peek && a.id === selectedId) {
|
||||
dom.tooltip.append(el('div', 'tooltip-tail', FLEET.fakeTail(a).slice(-4).join('\n')));
|
||||
}
|
||||
dom.tooltip.hidden = false;
|
||||
moveTooltip(event);
|
||||
}
|
||||
|
||||
function moveTooltip(event) {
|
||||
if (dom.tooltip.hidden) return;
|
||||
const pad = 14;
|
||||
const r = dom.tooltip.getBoundingClientRect();
|
||||
let x = event.clientX + pad;
|
||||
let y = event.clientY + pad;
|
||||
if (x + r.width > window.innerWidth - 8) x = event.clientX - r.width - pad;
|
||||
if (y + r.height > window.innerHeight - 8) y = event.clientY - r.height - pad;
|
||||
dom.tooltip.style.left = Math.max(8, x) + 'px';
|
||||
dom.tooltip.style.top = Math.max(8, y) + 'px';
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (peek) return;
|
||||
dom.tooltip.hidden = true;
|
||||
}
|
||||
|
||||
// ---- intervention rail -----------------------------------------------------
|
||||
|
||||
function railHead(label, count, tail, cycle) {
|
||||
const head = el('div', 'rail-head');
|
||||
let text = label + ' · ' + count;
|
||||
if (tail) text += ' · ' + tail;
|
||||
head.append(el('span', 'eyebrow', text));
|
||||
if (cycle) head.append(cycle);
|
||||
return head;
|
||||
}
|
||||
|
||||
function cyclePill(label, key, handler, exhausted) {
|
||||
const pill = el('button', 'cycle');
|
||||
pill.type = 'button';
|
||||
pill.dataset.exhausted = String(exhausted);
|
||||
pill.append(el('kbd', null, key), el('span', null, label));
|
||||
if (!exhausted) pill.addEventListener('click', handler);
|
||||
return pill;
|
||||
}
|
||||
|
||||
function nameRow(a, extras) {
|
||||
const row = el('div', 'item-head');
|
||||
row.append(glyph(a, { static: true }), el('span', 'item-name', a.shortName));
|
||||
row.append(el('span', 'item-where', a.worktree));
|
||||
if (extras) for (const node of extras) row.append(node);
|
||||
return row;
|
||||
}
|
||||
|
||||
function linkRow(row, a) {
|
||||
row.dataset.agent = a.id;
|
||||
row.addEventListener('mouseenter', () => {
|
||||
const node = mapGlyph(a.id);
|
||||
if (node) node.classList.add('is-linked');
|
||||
});
|
||||
row.addEventListener('mouseleave', drawLineage);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderRail() {
|
||||
const open = openDecisions();
|
||||
const attention = openList('attention');
|
||||
const failed = openList('failed');
|
||||
const needsYou = counts().needsYou;
|
||||
const nodes = [];
|
||||
|
||||
nodes.push(
|
||||
railHead(
|
||||
'Needs you',
|
||||
needsYou,
|
||||
null,
|
||||
cyclePill('next', '⏎', () => cycleNeedsYou(), needsYou === 0)
|
||||
)
|
||||
);
|
||||
|
||||
if (needsYou === 0) {
|
||||
nodes.push(el('p', 'rail-empty', 'Fleet clear — nothing is waiting on you.'));
|
||||
}
|
||||
|
||||
if (open.length === 0 && needsYou > 0) {
|
||||
nodes.push(el('p', 'rail-empty', 'No permission requests pending.'));
|
||||
}
|
||||
for (const entry of open) nodes.push(decisionCard(entry, entry.decision.key === expandedDecision));
|
||||
|
||||
if (attention.length === 0 && needsYou > 0) {
|
||||
nodes.push(el('p', 'rail-empty', 'No questions waiting.'));
|
||||
}
|
||||
attention.forEach((a, i) => nodes.push(attentionCard(a, i === 0)));
|
||||
|
||||
if (failed.length === 0 && needsYou > 0) {
|
||||
nodes.push(el('p', 'rail-empty', 'No failures.'));
|
||||
}
|
||||
failed.forEach((a, i) => nodes.push(failureCard(a, i === 0)));
|
||||
|
||||
const review = openList('review');
|
||||
nodes.push(
|
||||
railHead(
|
||||
'To review',
|
||||
review.length,
|
||||
'seen ≠ reviewed',
|
||||
cyclePill('next', 'F', () => cycleReview(), review.length === 0)
|
||||
)
|
||||
);
|
||||
if (review.length === 0) {
|
||||
nodes.push(el('p', 'rail-empty', 'Nothing awaiting review.'));
|
||||
}
|
||||
// Failures and PR-bearing runs are pulled above the fold no matter where they sort.
|
||||
const shown = review.filter((a, i) => i < 6 || Boolean(a.pr));
|
||||
shown.forEach((a, i) => nodes.push(reviewRow(a, i === 0)));
|
||||
const folded = review.length - shown.length;
|
||||
if (folded > 0) {
|
||||
nodes.push(
|
||||
el('p', 'fold', '… ' + folded + ' more below the fold — failures and PRs never fold')
|
||||
);
|
||||
}
|
||||
|
||||
const cleared = FLEET.agents.filter((a) => resolved.has(a.id)).length;
|
||||
if (cleared > 0) {
|
||||
const note = el('div', 'resolved');
|
||||
note.append(sampleGlyph('working'), el('span', null, cleared + ' cleared this session'));
|
||||
nodes.push(note);
|
||||
}
|
||||
|
||||
dom.rail.replaceChildren(...nodes);
|
||||
}
|
||||
|
||||
function decisionCard(entry, expanded) {
|
||||
const d = entry.decision;
|
||||
const agents = entry.agents;
|
||||
const card = el('article', 'item');
|
||||
card.dataset.kind = 'decision';
|
||||
card.dataset.motion = 'blink';
|
||||
card.dataset.decision = d.key;
|
||||
|
||||
const holdsSelection = agents.some((a) => a.id === selectedId);
|
||||
if (holdsSelection) card.classList.add('is-selected');
|
||||
|
||||
const head = el('div', 'item-head');
|
||||
head.append(el('span', 'item-title', d.title));
|
||||
if (holdsSelection) head.append(el('span', 'chip-selected', 'selected on map'));
|
||||
card.append(head);
|
||||
|
||||
if (!expanded) {
|
||||
const meta = el('div', 'meta');
|
||||
meta.style.marginTop = '4px';
|
||||
meta.textContent = agents.length + ' agents · ' + projectSummary(agents) + ' ▸';
|
||||
card.append(meta);
|
||||
card.style.cursor = 'pointer';
|
||||
card.addEventListener('click', () => {
|
||||
expandedDecision = d.key;
|
||||
render();
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
card.append(el('code', 'command', d.command));
|
||||
card.append(el('div', 'meta', agents.length + ' agents · ' + projectSummary(agents)));
|
||||
|
||||
const rows = el('div', 'microrows');
|
||||
for (const a of agents) {
|
||||
const row = el('div', 'microrow');
|
||||
if (a.id === selectedId) row.classList.add('is-selected');
|
||||
row.append(
|
||||
glyph(a, { static: true }),
|
||||
el('span', 'item-name', a.shortName),
|
||||
el('span', 'item-where', a.worktree),
|
||||
el('span', 'item-age', FLEET.ageLabel(a.startedAgo))
|
||||
);
|
||||
row.addEventListener('click', () => select(a.id));
|
||||
rows.append(linkRow(row, a));
|
||||
}
|
||||
card.append(rows);
|
||||
|
||||
const evidence = el('div', 'evidence');
|
||||
evidence.innerHTML = commonality(d, agents);
|
||||
card.append(evidence);
|
||||
|
||||
const scope = el('div', 'scope');
|
||||
scope.append(el('span', 'eyebrow', 'Scope'));
|
||||
const segmented = el('div', 'segmented');
|
||||
for (const option of SCOPES) {
|
||||
const seg = el('button', 'segment', option.label);
|
||||
seg.type = 'button';
|
||||
seg.setAttribute('aria-pressed', String(scopeByDecision.get(d.key) === option.id));
|
||||
seg.addEventListener('click', () => {
|
||||
scopeByDecision.set(d.key, option.id);
|
||||
render();
|
||||
});
|
||||
segmented.append(seg);
|
||||
}
|
||||
scope.append(segmented);
|
||||
card.append(scope);
|
||||
|
||||
const scopeId = scopeByDecision.get(d.key);
|
||||
const inScope = agentsInScope(agents, scopeId);
|
||||
if (inScope.length < agents.length) {
|
||||
card.append(
|
||||
el(
|
||||
'div',
|
||||
'meta',
|
||||
'Applies to ' +
|
||||
inScope[0].displayProject +
|
||||
' only — ' +
|
||||
(agents.length - inScope.length) +
|
||||
' agents in ' +
|
||||
projectSummary(agents.filter((a) => !inScope.includes(a))) +
|
||||
' stay blocked.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const actions = el('div', 'actions');
|
||||
const allow = el('button', 'btn btn-primary', 'Allow');
|
||||
allow.type = 'button';
|
||||
allow.addEventListener('click', () => resolve(inScope));
|
||||
const deny = el('button', 'btn btn-ghost', 'Deny');
|
||||
deny.type = 'button';
|
||||
deny.addEventListener('click', () => resolve(inScope));
|
||||
actions.append(allow, deny);
|
||||
actions.append(el('span', 'meta', 'unblocks ' + inScope.length + ' of ' + agents.length));
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function agentsInScope(agents, scopeId) {
|
||||
if (scopeId !== 'project') return agents.slice();
|
||||
const anchor = agents.find((a) => a.id === selectedId) || agents[0];
|
||||
return agents.filter((a) => a.displayProject === anchor.displayProject);
|
||||
}
|
||||
|
||||
function projectSummary(agents) {
|
||||
const projects = [...new Set(agents.map((a) => a.displayProject))];
|
||||
return projects.length <= 2 ? projects.join(' + ') : projects.length + ' projects';
|
||||
}
|
||||
|
||||
/* What the blocked agents actually have in common — read off the set, not asserted. */
|
||||
function commonality(d, agents) {
|
||||
const bits = ['same command <b>' + escapeHtml(d.command) + '</b>'];
|
||||
const types = [...new Set(agents.map((a) => a.agentType))];
|
||||
if (types.length === 1) bits.push('all <b>' + escapeHtml(types[0]) + '</b>');
|
||||
const projects = [...new Set(agents.map((a) => a.displayProject))];
|
||||
const worktrees = new Set(agents.map((a) => a.worktree)).size;
|
||||
bits.push(
|
||||
projects.length === 1
|
||||
? worktrees + ' worktrees in <b>' + escapeHtml(projects[0]) + '</b>'
|
||||
: worktrees + ' worktrees across <b>' + escapeHtml(projects.join(' + ')) + '</b>'
|
||||
);
|
||||
if (agents.every((a) => a.alive)) bits.push('all still running');
|
||||
return 'all ' + agents.length + ' share: ' + bits.join(' · ');
|
||||
}
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
value.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]);
|
||||
|
||||
function attentionCard(a, expanded) {
|
||||
const card = el('article', 'item');
|
||||
card.dataset.kind = 'attention';
|
||||
card.dataset.motion = 'blink';
|
||||
if (a.id === selectedId) card.classList.add('is-selected');
|
||||
card.append(nameRow(a, [el('span', 'item-age', FLEET.ageLabel(a.startedAgo))]));
|
||||
card.append(el('p', 'question', '“' + a.question.text + '”'));
|
||||
|
||||
if (expanded) {
|
||||
const options = el('div', 'options');
|
||||
a.question.options.forEach((label, i) => {
|
||||
const button = el('button', 'option');
|
||||
button.type = 'button';
|
||||
button.append(el('kbd', null, String(i + 1)), el('span', null, label));
|
||||
button.addEventListener('click', () => resolve([a]));
|
||||
options.append(button);
|
||||
});
|
||||
card.append(options);
|
||||
|
||||
const reply = el('div', 'reply');
|
||||
const input = el('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Reply to ' + a.shortName.slice(0, 24) + '…';
|
||||
const send = el('button', 'btn', 'Send');
|
||||
send.type = 'button';
|
||||
send.addEventListener('click', () => resolve([a]));
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') resolve([a]);
|
||||
});
|
||||
reply.append(input, send);
|
||||
card.append(reply);
|
||||
}
|
||||
card.addEventListener('click', () => select(a.id));
|
||||
return linkRow(card, a);
|
||||
}
|
||||
|
||||
function failureCard(a, expanded) {
|
||||
const card = el('article', 'item');
|
||||
card.dataset.kind = 'failed';
|
||||
card.dataset.motion = 'blink';
|
||||
if (a.id === selectedId) card.classList.add('is-selected');
|
||||
card.append(nameRow(a, [el('span', 'item-age', FLEET.ageLabel(a.startedAgo))]));
|
||||
card.append(el('p', 'failure', a.failure));
|
||||
if (expanded) {
|
||||
const actions = el('div', 'actions');
|
||||
const retry = el('button', 'btn btn-ghost', 'Retry');
|
||||
retry.type = 'button';
|
||||
retry.addEventListener('click', () => resolve([a]));
|
||||
const open = el('button', 'btn btn-ghost', 'Open terminal');
|
||||
open.type = 'button';
|
||||
open.addEventListener('click', () => select(a.id));
|
||||
actions.append(retry, open);
|
||||
card.append(actions);
|
||||
}
|
||||
card.addEventListener('click', () => select(a.id));
|
||||
return linkRow(card, a);
|
||||
}
|
||||
|
||||
function reviewRow(a, expanded) {
|
||||
const card = el('article', 'item');
|
||||
card.dataset.kind = 'review';
|
||||
card.dataset.motion = 'pulse';
|
||||
if (a.id === selectedId) card.classList.add('is-selected');
|
||||
const extras = [];
|
||||
if (a.pr) extras.push(el('span', 'pr-badge', '#' + a.pr.number));
|
||||
extras.push(el('span', 'item-age', FLEET.ageLabel(a.durationMs)));
|
||||
card.append(nameRow(a, extras));
|
||||
if (expanded) {
|
||||
const actions = el('div', 'actions');
|
||||
const diff = el('button', 'btn', 'Review diff');
|
||||
diff.type = 'button';
|
||||
diff.addEventListener('click', () => select(a.id));
|
||||
const mark = el('button', 'btn btn-ghost', 'Mark reviewed');
|
||||
mark.type = 'button';
|
||||
mark.addEventListener('click', () => resolve([a]));
|
||||
actions.append(diff, mark);
|
||||
card.append(actions);
|
||||
}
|
||||
card.addEventListener('click', () => select(a.id));
|
||||
return linkRow(card, a);
|
||||
}
|
||||
|
||||
// ---- actions ---------------------------------------------------------------
|
||||
|
||||
function resolve(agents) {
|
||||
for (const a of agents) resolved.add(a.id);
|
||||
if (selectedId && resolved.has(selectedId)) selectedId = null;
|
||||
const stillOpen = openDecisions();
|
||||
if (!stillOpen.some((d) => d.decision.key === expandedDecision)) {
|
||||
expandedDecision = stillOpen.length ? stillOpen[0].decision.key : null;
|
||||
}
|
||||
touch();
|
||||
render();
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
selectedId = id;
|
||||
peek = false;
|
||||
dom.tooltip.hidden = true;
|
||||
const agent = byId.get(id);
|
||||
if (agent && agent.decisionKey && !resolved.has(id)) expandedDecision = agent.decisionKey;
|
||||
touch();
|
||||
render();
|
||||
for (const node of dom.bands.querySelectorAll('.glyph.is-selected')) {
|
||||
node.classList.remove('is-selected');
|
||||
}
|
||||
const node = mapGlyph(id);
|
||||
if (node) node.classList.add('is-selected');
|
||||
drawLineage();
|
||||
}
|
||||
|
||||
function needsYouInMapOrder() {
|
||||
const order = [];
|
||||
for (const wt of FLEET.byWorktree) for (const a of wt.agents) if (isUnacked(a)) order.push(a);
|
||||
return order;
|
||||
}
|
||||
|
||||
/* One gesture: move to the next thing that needs you and attach to it. */
|
||||
function cycleNeedsYou() {
|
||||
const order = needsYouInMapOrder();
|
||||
if (order.length === 0) return;
|
||||
const at = order.findIndex((a) => a.id === selectedId);
|
||||
const next = order[(at + 1) % order.length];
|
||||
select(next.id);
|
||||
revealInRail(next.id);
|
||||
}
|
||||
|
||||
function cycleReview() {
|
||||
const order = openList('review');
|
||||
if (order.length === 0) return;
|
||||
const at = order.findIndex((a) => a.id === selectedId);
|
||||
const next = order[(at + 1) % order.length];
|
||||
select(next.id);
|
||||
revealInRail(next.id);
|
||||
}
|
||||
|
||||
function revealInRail(id) {
|
||||
const node = dom.rail.querySelector('.item[data-agent="' + cssEscape(id) + '"]')
|
||||
|| dom.rail.querySelector('.microrow[data-agent="' + cssEscape(id) + '"]');
|
||||
if (!node) return;
|
||||
node.scrollIntoView({ block: 'nearest' });
|
||||
const card = node.closest('.item') || node;
|
||||
card.classList.add('is-flash');
|
||||
setTimeout(() => card.classList.remove('is-flash'), 700);
|
||||
}
|
||||
|
||||
function scrollIntoBand(id) {
|
||||
const node = mapGlyph(id);
|
||||
if (node) node.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ---- status strip ----------------------------------------------------------
|
||||
|
||||
function renderLegend() {
|
||||
dom.legendShape.replaceChildren(
|
||||
...LEGEND_ORDER.map((state) => {
|
||||
const item = el('span');
|
||||
item.append(sampleGlyph(state), el('span', null, LEGEND_LABEL[state]));
|
||||
return item;
|
||||
})
|
||||
);
|
||||
const keys = [
|
||||
['Space', 'peek'],
|
||||
['⏎', 'attach'],
|
||||
['1–3', 'answer'],
|
||||
['F', 'review'],
|
||||
[MOD + 'K', 'search'],
|
||||
];
|
||||
dom.legendKeys.replaceChildren(
|
||||
...keys.map(([key, label]) => {
|
||||
const item = el('span');
|
||||
item.append(el('kbd', null, key), el('span', null, label));
|
||||
return item;
|
||||
})
|
||||
);
|
||||
dom.searchKbd.textContent = MOD + 'K';
|
||||
}
|
||||
|
||||
/* Datadog's rule: a board that repaints under the operator's cursor loses the operator.
|
||||
The pause clock is the only wall-clock read in this file — every age is FLEET.now-based. */
|
||||
function renderCapture() {
|
||||
const stamp = new Date(FLEET.now).toISOString().slice(11, 16);
|
||||
const paused = Date.now() - lastInteraction < PAUSE_MS;
|
||||
dom.capture.dataset.paused = String(paused);
|
||||
dom.capture.textContent =
|
||||
'captured ' + stamp + ' UTC · ' + (paused ? 'refresh paused while interacting' : 'refresh armed');
|
||||
}
|
||||
|
||||
function touch() {
|
||||
lastInteraction = Date.now();
|
||||
renderCapture();
|
||||
}
|
||||
|
||||
// ---- keyboard --------------------------------------------------------------
|
||||
|
||||
function onKeyDown(event) {
|
||||
const focused = document.activeElement || document.body;
|
||||
const typing = /^(INPUT|TEXTAREA)$/.test(focused.tagName);
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
dom.search.focus();
|
||||
dom.search.select();
|
||||
touch();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (typing) {
|
||||
dom.search.value = '';
|
||||
query = '';
|
||||
dom.search.blur();
|
||||
applySearch();
|
||||
} else if (peek) {
|
||||
peek = false;
|
||||
dom.tooltip.hidden = true;
|
||||
} else {
|
||||
selectedId = null;
|
||||
render();
|
||||
}
|
||||
touch();
|
||||
return;
|
||||
}
|
||||
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
cycleNeedsYou();
|
||||
} else if (event.key.toLowerCase() === 'f') {
|
||||
cycleReview();
|
||||
} else if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
togglePeek();
|
||||
} else if (['1', '2', '3'].includes(event.key)) {
|
||||
const first = openList('attention')[0];
|
||||
if (first && Number(event.key) <= first.question.options.length) resolve([first]);
|
||||
}
|
||||
touch();
|
||||
}
|
||||
|
||||
function togglePeek() {
|
||||
const agent = selectedId && byId.get(selectedId);
|
||||
if (!agent) return;
|
||||
peek = !peek;
|
||||
if (!peek) {
|
||||
dom.tooltip.hidden = true;
|
||||
return;
|
||||
}
|
||||
scrollIntoBand(agent.id);
|
||||
const node = mapGlyph(agent.id);
|
||||
const r = node ? node.getBoundingClientRect() : { left: 40, bottom: 120 };
|
||||
showTooltip(agent, { clientX: r.left, clientY: r.bottom });
|
||||
}
|
||||
|
||||
// ---- boot ------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
const railTop = dom.rail.scrollTop;
|
||||
const bandTops = [...dom.bands.querySelectorAll('.cells')].map((node) => node.scrollTop);
|
||||
renderDials();
|
||||
renderBands();
|
||||
renderRail();
|
||||
dom.rail.scrollTop = railTop;
|
||||
[...dom.bands.querySelectorAll('.cells')].forEach((node, i) => {
|
||||
if (bandTops[i] != null) node.scrollTop = bandTops[i];
|
||||
});
|
||||
if (selectedId) {
|
||||
const node = mapGlyph(selectedId);
|
||||
if (node) node.classList.add('is-selected');
|
||||
}
|
||||
applySearch();
|
||||
drawLineage();
|
||||
}
|
||||
|
||||
dom.search.addEventListener('input', () => {
|
||||
query = dom.search.value.trim();
|
||||
applySearch();
|
||||
touch();
|
||||
});
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
dom.rail.addEventListener('scroll', touch, { passive: true });
|
||||
document.addEventListener('pointerdown', touch);
|
||||
window.addEventListener('resize', drawLineage);
|
||||
setInterval(renderCapture, 1000);
|
||||
|
||||
renderLegend();
|
||||
renderCapture();
|
||||
render();
|
||||
|
||||
/* Pre-seeded so the board opens mid-investigation rather than empty: the first coalesced
|
||||
decision's agent that also has resolvable lineage — selected on the map, its edge drawn to
|
||||
its child worktree, and its decision card ringed in the rail. */
|
||||
const seed = (FLEET.decisions[0] ? FLEET.decisions[0].agents : []).find(
|
||||
(a) => (childrenOf.get(a.id) || []).length > 0 || (a.parent && byId.has(a.parent))
|
||||
);
|
||||
if (seed) select(seed.id);
|
||||
requestAnimationFrame(drawLineage);
|
||||
})();
|
||||
|
After Width: | Height: | Size: 237 KiB |
@@ -0,0 +1,251 @@
|
||||
# Survey: Agent-product fleet/canvas UIs for Orca fleet-canvas inspiration
|
||||
|
||||
**Task:** `task_e78c9946452e` / dispatch `ctx_f109c91443ca`
|
||||
**Run objective:** Survey current agent-product fleet/canvas UIs (X + web) for Orca fleet-canvas inspiration
|
||||
**Task title as dispatched:** `shape probe stripped` (abbreviated; coordinator intended a full research brief that failed to land due to orchestration identity issues)
|
||||
**Mode:** Research-only (web + X). No worktree file modifications.
|
||||
**Date:** 2026-08-01
|
||||
**Author:** Grok worker terminal `term_5f390873-6cba-4a53-a793-dfe5a439661b`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
The 2026 coding-agent market has split the **control plane** (where you see and steer many agents) away from the **editor** as the primary surface. Products cluster into five UI archetypes:
|
||||
|
||||
| Archetype | Core metaphor | Best for | Exemplars |
|
||||
|-----------|---------------|----------|-----------|
|
||||
| **A. Infinite canvas / spatial tiles** | Pan-zoom room of terminals, files, panes | Spatial memory, multi-machine, ad-hoc arrangement | Collaborator, 49Agents, Cate |
|
||||
| **B. Containment / topology map** | Project → worktree → agent nodes | Fleet awareness at 50–150+ agents | Orca rings/map mocks; infra lineage (Nomad, Datadog host map) |
|
||||
| **C. Intervention / decision inbox** | “What needs me?” + respond-in-place | Human bottleneck (permissions, questions, review) | LangChain Agent Inbox pattern; Orca strips/triage; Mission Control dashboards |
|
||||
| **D. Work-orchestration board** | Kanban / issue → agent workspace → diff review | Parallel features with explicit PR lifecycle | Vibe Kanban, Conductor, Weave Fleet |
|
||||
| **E. Agent-first app shell** | Sessions list + agent as primary chrome | Daily driver multi-agent IDE | Cursor Glass / Agents Window, OpenHands Agent Canvas |
|
||||
|
||||
**Orca’s existing control-room tenets already outpace most shipping products** on scale (accounting line, coalesced decisions, latched review, spatial stability). The gap in the market is not “another canvas for terminals” — it is **fleet-scale awareness + decision-centric intervention**, with canvas/topology as the *awareness layer* and inbox/strips as the *action layer*.
|
||||
|
||||
---
|
||||
|
||||
## 2. Provenance note (task shape)
|
||||
|
||||
- Dispatched task spec literally: **“shape probe stripped”** (coordinator hit `legacy_read_only` / env-strip path while creating the task; title was truncated).
|
||||
- Run objective (authoritative): *Survey current agent-product fleet/canvas UIs (X + web) for Orca fleet-canvas inspiration*.
|
||||
- Coordinator tail showed intent to mail a full brief: research-only, web + X, do not modify worktree files, survey agent-orchestration product UIs.
|
||||
- Parent design context in `albacore`: multi-gen mocks (`agent-fleet-concepts` → `agent-fleet-refined` → `agent-viewer-alternatives` → `agent-fleet-control-room`) including Iron Man–style **Fleet Rings** (project → worktree → agent).
|
||||
- Worker could not deliver lifecycle heartbeats (`legacy_read_only` on `send`/`check`/`ask` mutations) but could read `task-list` / `dispatch-show` / `run-show`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Product inventory (web + X, 2025–2026)
|
||||
|
||||
### 3.1 Infinite canvas / spatial tile IDEs
|
||||
|
||||
#### Collaborator (`collabs-inc/collab-public`, Electron)
|
||||
- **UI:** Infinite pan-zoom canvas + left navigator (workspaces, file tree). Double-click empty canvas → terminal tile; drag files → note/code/image tiles.
|
||||
- **Agent model:** Terminals *are* the agent surface (Claude Code, Codex, Gemini CLI, any TTY agent). Not a special agent graph.
|
||||
- **Persistence:** `~/.collaborator/` JSON (tiles, viewport, workspaces). Local-first, no accounts.
|
||||
- **Stack signals:** Electron + xterm.js + Monaco + D3 (force graph available).
|
||||
- **Takeaways for Orca:** Spatial freestyle is great for 5–15 agents; **force-directed / free placement does not scale** to Orca’s 100–400 terminal fleets. Collaborator proves the *tile + hover terminal* interaction people love; Orca rings already formalize placement via containment.
|
||||
|
||||
#### 49Agents (49agents.com / GitHub 49Agents)
|
||||
- **Pitch:** “Hard to manage 15 agent tabs? … multi-💻, 📱-friendly ✨ 2D canvas.”
|
||||
- **UI patterns:** Infinite canvas of terminals, git graphs, notes, resource monitors; multi-machine without SSH; broadcast input; issue board tags; resume sessions; mobile check-in.
|
||||
- **X signal:** Positioning as the IDE becoming *one view among many* (tickets, PRs, agents, specs co-located).
|
||||
- **Takeaways:** Multi-machine canvas + resource gauges map cleanly to Orca’s local/SSH/WSL host model. Broadcast-input and “tag terminal with issue” are cheap wins. Free tier caps (6 terminals) show they still optimize for dozens, not hundreds.
|
||||
|
||||
#### Cate (community / Show HN lineage)
|
||||
- Open-source canvas IDE placing editors, terminals, browsers, docs, git worktrees on one surface — same family as Collaborator/49Agents.
|
||||
|
||||
### 3.2 Agent-first product shells (not freeform canvas)
|
||||
|
||||
#### OpenHands Agent Canvas (openhands.dev/product/canvas, `@openhands/agent-canvas`)
|
||||
- **Positioning:** “From prompting to process” — browser/desktop control surface for agentic work; **parallel agents in isolated git worktrees**, automations (cron/webhook/event), BYO agent via **Agent Client Protocol (ACP)** (Claude Code, Codex, Gemini CLI, OpenHands).
|
||||
- **UI:** Conversation/home list, chat, automations list/detail, skills, MCP — more **session + automation studio** than topology map.
|
||||
- **Scale story:** Local agent-server → self-hosted VM → OpenHands Cloud; same UI across backends.
|
||||
- **Takeaways:** ACP multi-harness is the interoperability story Orca already partially has (Claude/Codex/OMP/Grok/…). Automations as first-class peers of interactive sessions is a product move Orca orchestration is still mostly CLI-shaped.
|
||||
|
||||
#### Cursor Glass / Agents Window (Cursor 3, ~Mar–Apr 2026)
|
||||
- **Positioning:** Agent management as **primary surface**; editor secondary (“passenger seat”).
|
||||
- **UI:** Unified workspace for agents, repos, cloud tasks; multi-repo; local ↔ cloud handoff mid-task; integrated browser/terminal/files; agent filtering; project threads.
|
||||
- **Landscape role (Addy Osmani):** Control plane becomes primary; editor is one instrument underneath.
|
||||
- **Takeaways:** Validates Orca’s bet that a global agent viewer is not a “settings page” — it is the daily driver at fleet scale. Glass still skews **session list / threads**, not containment topology.
|
||||
|
||||
#### Conductor (conductor.build, Melty Labs, YC S24)
|
||||
- **UI:** Polished macOS dashboard: parallel Claude Code / Codex / Cursor agents in isolated worktrees; **diff-first review**; checkpoints; spotlight testing; Linear issue start.
|
||||
- **Mental model:** CEO of a small org — glance at work, dive into diffs, merge.
|
||||
- **Sweet spot:** 3–8 parallel features on one repo.
|
||||
- **Takeaways:** Closest commercial cousin to Orca’s worktree-per-agent model. Diff-first review + worktree lifecycle is table stakes; Conductor does **not** solve 100+ agent accounting.
|
||||
|
||||
#### Weave Agent Fleet (tryweave.io/fleet)
|
||||
- **UI:** Local web dashboard (`localhost:3000`): multi-session OpenCode management, status, diffs, event streams; SQLite persistence.
|
||||
- **Unique twist:** *Agents orchestrate agents* via fleet-orchestration skill (spawn children, callbacks, conflict detection on overlapping files) — not only human dashboard.
|
||||
- **Takeaways:** Human fleet UI + agent-driven fan-out is exactly Orca orchestration’s dual audience. Callback-driven parent/child completion > polling silence (matches Orca’s “silence ≠ death” invariant).
|
||||
|
||||
#### Vibe Kanban (vibekanban.com / BloopAI; sunsetting commercial, OSS continues)
|
||||
- **UI:** Plan → Prompt → Review. Kanban issues, agent workspaces (branch + terminal + dev server), inline diff comments, built-in browser QA, multi-agent switch (Claude, Codex, Gemini, Cursor, Amp, OpenCode, …).
|
||||
- **Thesis:** Bottleneck shifted to **planning and review**, not typing.
|
||||
- **Takeaways:** Strongest **workflow board** pattern. Orca should not replace Linear/GitHub issues, but a fleet view that surfaces *review-ready* and *blocked* columns is proven.
|
||||
|
||||
### 3.3 Command-center / mission-control aesthetics (X-heavy)
|
||||
|
||||
| Product / post | UI idea | Relevance |
|
||||
|----------------|---------|-----------|
|
||||
| **Moltbot Agent Canvas UI** (George Pickett, Jan 2026) | Local-first visual command center: what’s running, live stream, shared context | Early “terminals bad for teams of agents” narrative |
|
||||
| **Mission Control + OpenClaw** (Kol Tregaskes, Feb 2026) | React/Convex dashboard, 10 agents, threaded agent debates, claims/reviews | Social/team metaphor for agent collab |
|
||||
| **AgentCommand** (Matt Schlicht / MattPRD) | Dashboard for agents running agents; 1000+ spin-up; revenue/deploys/diffs live | Extreme-scale *metrics wall* more than IDE |
|
||||
| **DASH** (Peter Broas) | Agentic workspace widgets + MCP bar; open agents on canvas from chat | Widget mosaic + AI bar as router |
|
||||
| **LangGraph multi-agent dashboards** | React Flow / Cytoscape graph + fleet rail + logs | Graph orchestration visualization (fan-out/verify/merge) |
|
||||
|
||||
### 3.4 Cloud async agent panels (Tier 3)
|
||||
|
||||
Claude Code Web, GitHub Copilot Coding Agent Agents panel, Jules, Codex Web — **issue → cloud VM → PR** lists with plan approval and evidence. Less “canvas,” more **async job board**. Important so Orca fleet UI can later show *cloud/async* siblings without looking like a second product.
|
||||
|
||||
### 3.5 Historical community seed (Orca-specific lore)
|
||||
|
||||
- Parent conversation recalled **amethystliang** community PR / canvas-style agent view as inspiration for Iron Man **fleet-rings**.
|
||||
- **OpenHands Agent Canvas** (and earlier forks like rbren/agent-canvas, voicetreelab/agent-canvas) is the current canonical open “Agent Canvas” brand — distinct from freeform tile canvases.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-cutting UI patterns (what actually ships)
|
||||
|
||||
### 4.1 Layout patterns that won
|
||||
|
||||
1. **Session / card list** (most shipping products) — simple, scannable, collapses at scale.
|
||||
2. **Kanban / work board** — optimizes human review queue.
|
||||
3. **Infinite freeform canvas** — delightful for small fleets; poor default at Orca scale without auto-layout.
|
||||
4. **Containment topology** (rare in agent products; common in infra) — project/worktree/host nesting. **Orca mocks lead here.**
|
||||
5. **Graph of agent workflow** (LangGraph UIs) — good for *orchestration topology*, bad as sole fleet map of live PTYs.
|
||||
|
||||
### 4.2 Interaction patterns that matter
|
||||
|
||||
| Pattern | Who does it | Orca fit |
|
||||
|---------|-------------|----------|
|
||||
| Hover → live terminal preview | Conductor-class dashboards; Orca rings mocks | **Keep** — already in refined mocks |
|
||||
| Diff-first review in board | Conductor, Vibe Kanban, Weave | **Keep** — source-control deep link |
|
||||
| Respond-in-place (permissions/questions) | LangChain Agent Inbox; Orca control-room | **Differentiator** — few products coalesce decisions |
|
||||
| Latched “to review” until explicit ack | Orca control-room (ISA-18.2) | **Differentiator** — almost no one ships this |
|
||||
| Spatial stability (no resort-by-activity) | Orca; infra tools after force-layout failures | **Differentiator** |
|
||||
| Local ↔ cloud handoff | Cursor Glass | Future Orca host/runtime story |
|
||||
| Multi-machine on one canvas | 49Agents | Aligns with SSH/WSL/remote runtime |
|
||||
| Agent-spawned children + callbacks | Weave Fleet skill | Aligns with orchestration Run/Dispatch |
|
||||
| Automations as first-class | OpenHands Agent Canvas | Aligns with Orca automations product |
|
||||
| Broadcast input / multi-terminal send | 49Agents | Power feature, not default |
|
||||
|
||||
### 4.3 Visual encoding consensus
|
||||
|
||||
- **State color + non-color channel** (shape, motion, badge) — Orca already enforces styleguide + AgentStateDot.
|
||||
- **Quiet chrome / saturated only for human-needed states** — matches Orca STYLEGUIDE and control-room grayscale-normal budget.
|
||||
- **Liveness ≠ importance** — working can be dim; needs-you blinks; motionless board = healthy (Orca tenet; rare elsewhere — most UIs celebrate busy spinners).
|
||||
|
||||
### 4.4 Scale failure modes observed
|
||||
|
||||
- Freeform canvases: layout entropy, lost agents off-viewport.
|
||||
- Force-directed graphs: Kiali/Nomad history shows physics layouts get deleted.
|
||||
- Pure terminal walls: impressive demos (Orca wall mock; AgentCommand scale porn), unusable without aggressive folding.
|
||||
- Pure session lists: lose project/worktree structure Orca users live in.
|
||||
|
||||
---
|
||||
|
||||
## 5. Implications for Orca fleet-canvas
|
||||
|
||||
### 5.1 Do not copy
|
||||
|
||||
- **Default freeform infinite canvas as the primary fleet UI** for 100–400 terminals — Collaborator/49Agents stop being legible past ~15–30 panes.
|
||||
- **Force-directed agent graphs as the home view** — use graphs only for *selected lineage* (already in fleet-map mock).
|
||||
- **Busy neon “everything is spinning” aesthetics** — contradicts Orca’s healthy-quiet fleet tenet.
|
||||
- **Replacing the editor** as a separate Cursor Glass clone — Orca’s strength is *terminals as agents* already embedded in the IDE.
|
||||
|
||||
### 5.2 Steal shamelessly
|
||||
|
||||
1. **Conductor:** worktree lifecycle + diff-first review polish + “see at a glance then dive.”
|
||||
2. **Vibe Kanban:** review column as first-class; inline comments back to agent; plan/prompt/review stages as language.
|
||||
3. **Weave Fleet:** parent/child callbacks; overlap conflict summary after parallel children; local SQLite-style durability for fleet view state.
|
||||
4. **OpenHands Agent Canvas:** multi-harness ACP story; automations as peers of sessions; backend switcher in chrome.
|
||||
5. **49Agents:** multi-host resource strip; mobile glance; issue-tagged terminals; broadcast as power tool.
|
||||
6. **Cursor Glass:** agent-first *mode* (full-screen fleet) without abandoning IDE embedding.
|
||||
|
||||
### 5.3 Double down (Orca already ahead)
|
||||
|
||||
From `agent-fleet-control-room` / `agent-fleet-refined` READMEs — these remain the right product spine:
|
||||
|
||||
1. Whole fleet always accounted for (count line).
|
||||
2. State preattentive (color + glyph + motion rate).
|
||||
3. One step from see → respond in place.
|
||||
4. Spatial stability via **project → worktree → agent** containment (Fleet Rings).
|
||||
5. Latched TO REVIEW until explicit review.
|
||||
6. Actionable unit = **coalesced decision**, not agent card spam.
|
||||
7. Degrade 5 → 150+ (labels hide before agents; failures/PRs never fold).
|
||||
8. Liveness aesthetic: still board = good.
|
||||
|
||||
**Recommended hybrid (already sketched in mission-control.html):**
|
||||
**Left/center:** containment map or rings (awareness).
|
||||
**Right/rail:** intervention strips / triage inbox (action).
|
||||
**Escalation:** pin terminal popover (not full canvas navigation).
|
||||
Optional tertiary: **terminal wall** as a density mode, not the default.
|
||||
|
||||
### 5.4 Design tenets distilled for next prototype pass
|
||||
|
||||
| # | Tenet | Market evidence |
|
||||
|---|-------|-----------------|
|
||||
| 1 | Control plane is primary at fleet scale | Cursor Glass, Conductor, OpenHands Canvas |
|
||||
| 2 | Containment > freeform for 50+ | Infra tools abandoned physics; freeform canvases cap low |
|
||||
| 3 | Decision unit > agent unit | Inbox products; almost no competitor coalesces |
|
||||
| 4 | Review latch is a safety system | Industry ships fire-and-forget “done”; Orca should not |
|
||||
| 5 | Hover preview, click escalate | Universal desire path |
|
||||
| 6 | Diff + PR are first-class cards | Conductor/Vibe Kanban |
|
||||
| 7 | Multi-host is part of the map | 49Agents; Orca SSH/WSL reality |
|
||||
| 8 | Agents-orchestrating-agents must be visible | Weave; Orca Dispatch tree |
|
||||
| 9 | Quiet is healthy | Control-room research; avoid spinner theater |
|
||||
| 10 | Automations sit beside interactive agents | OpenHands |
|
||||
|
||||
---
|
||||
|
||||
## 6. Competitive positioning one-liner
|
||||
|
||||
> **Most agent products show you sessions. Orca should show you the fleet and the decisions that unblock it — with worktree containment for spatial memory and an intervention rail for the human bottleneck.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Suggested next research/design steps (out of scope for this task)
|
||||
|
||||
1. Side-by-side screenshot matrix: Conductor / Glass / OpenHands Canvas / 49Agents / Vibe Kanban vs Orca `mission-control` / `fleet-rings` / `fleet-strips` at the same 100-agent fixture.
|
||||
2. User-test **map+rail hybrid** vs pure triage list at 20 vs 100 agents.
|
||||
3. Prototype **Dispatch/Run lineage overlay** on the containment map (orchestration provenance).
|
||||
4. Evaluate ACP-style multi-harness labeling in the fleet chrome (agent kind badge).
|
||||
|
||||
---
|
||||
|
||||
## 8. Sources (selected)
|
||||
|
||||
### Web
|
||||
- OpenHands Agent Canvas: https://www.openhands.dev/product/canvas
|
||||
- OpenHands ACP blog / docs: openhands.dev docs agent-canvas
|
||||
- Weave Agent Fleet: https://tryweave.io/fleet/
|
||||
- Conductor: https://www.conductor.build/
|
||||
- Vibe Kanban: https://vibekanban.com/ · https://github.com/BloopAI/vibe-kanban
|
||||
- Collaborator: https://github.com/collabs-inc/collab-public
|
||||
- 49Agents: https://www.49agents.com/ · https://github.com/49agents/49agents
|
||||
- Addy Osmani — *The Code Agent Orchestra* (Mar 2026): https://addyosmani.com/blog/code-agent-orchestra/
|
||||
- Cursor Glass / Agents Window coverage (Cursor 3, 2026)
|
||||
- awesome-agent-orchestrators: https://github.com/andyrewlee/awesome-agent-orchestrators
|
||||
|
||||
### X (semantic + keyword)
|
||||
- Moltbot Agent Canvas UI (George Pickett)
|
||||
- Mission Control OpenClaw dashboard (Kol Tregaskes)
|
||||
- AgentCommand 1000+ agents (Matt Schlicht)
|
||||
- 49Agents canvas IDE positioning
|
||||
- OpenHands Agent Canvas product posts
|
||||
- Multi-agent graph dashboard build notes (LangGraph + React Flow patterns)
|
||||
|
||||
### Local Orca prior art (read-only, parent albacore)
|
||||
- `agent-fleet-concepts/`, `agent-fleet-refined/`, `agent-viewer-alternatives/`, `agent-fleet-control-room/`
|
||||
- Especially: Fleet Rings, Command Deck, mission-control hybrid, fleet-map, fleet-strips, fleet-triage tenets README
|
||||
|
||||
---
|
||||
|
||||
## 9. Completion status
|
||||
|
||||
- Research survey complete for abbreviated task interpreted via run objective.
|
||||
- Full coordinator brief never arrived (orchestration mutation path broken for this dispatch).
|
||||
- No worktree files modified.
|
||||
- Artifact path: `/tmp/orca-research/task_e78c9946452e-fleet-canvas-ui-survey.md`
|
||||
@@ -0,0 +1,124 @@
|
||||
# Agent fleet visualization survey (k8s-style canvas inspiration)
|
||||
Date: 2026-08-01 | Mode: research-only | Task: task_e78c9946452e
|
||||
|
||||
## Ranking criterion
|
||||
Higher = closer to a VISUAL multi-agent fleet surface (canvas/map/graph/nodes/minimap), not lists/kanbans. Ship-now products preferred.
|
||||
|
||||
---
|
||||
|
||||
## RANKED — VISUAL FLEET / CANVAS FIRST
|
||||
|
||||
### 1. OpenCove (DeadWaveWave) — ★ PRIMARY REFERENCE
|
||||
**Fleet view:** Infinite pan/zoom 2D canvas (@xyflow/react) of agent sessions, terminals (xterm.js+node-pty), tasks, and notes as free-placed nodes; parallel CLI agents (Claude Code/Codex) stay spatially co-visible; persistent viewport/layout; global search + control center; workspace isolation via dirs/worktrees; Electron desktop + experimental Worker Web UI.
|
||||
**Why #1:** Closest shipping "agent fleet canvas" with modern node graph stack; spatial context over tabs; screenshots in repo.
|
||||
**Links:** https://github.com/DeadWaveWave/opencove | https://github.com/DeadWaveWave/opencove/releases | https://github.com/DeadWaveWave/opencove/blob/main/README.md | preview assets https://github.com/DeadWaveWave/opencove/blob/main/assets/images/opencove_app_preview_readme.jpg | short demo https://www.youtube.com/shorts/A8l5JDmtlbA | xyflow MiniMap (stack sibling) https://reactflow.dev/api-reference/components/minimap
|
||||
|
||||
### 2. Collaborator (collabs-inc) — infinite tile canvas
|
||||
**Fleet view:** Infinite pan/zoom canvas of terminal/note/code/image tiles; double-click → terminal tile (agent host); drag files from navigator; grid snap; multi-workspace; local-first Electron; D3 available for graphs.
|
||||
**Why high:** Pure spatial terminal fleet without orchestration lock-in.
|
||||
**Links:** https://github.com/collabs-inc/collab-public | https://github.com/collabs-inc/collab-public/blob/main/README.md | https://github.com/collaborator-ai/collab-public/releases/latest
|
||||
|
||||
### 3. 49Agents — multi-machine 2D agentic IDE canvas
|
||||
**Fleet view:** Infinite zoomable canvas of terminals, git graphs, notes, resource monitors as panes; multi-machine without SSH; broadcast input; issue tags; mobile check-in; free tier caps terminals.
|
||||
**Why high:** Explicit "15 agent tabs → one canvas" product pitch; multi-host = k8s-node vibe.
|
||||
**Links:** https://www.49agents.com/ | https://github.com/49agents/49agents | https://x.com/49agents | HN https://news.ycombinator.com/item?id=47942287
|
||||
|
||||
### 4. cmux — terminal + experimental freeform canvas + minimap
|
||||
**Fleet view:** Primary = vertical-tab Ghostty terminal with notification rings when agents need attention; subagents become native panes; programmable CLI. Experimental freeform 2D canvas layout for workspace panes + minimap navigation polish (PRs #5987, #6105).
|
||||
**Why high for Orca:** Best-in-class attention encoding (rings/badges); minimap is the right "fleet overview" affordance even if still experimental.
|
||||
**Links:** https://cmux.com/ | https://github.com/manaflow-ai/cmux | changelog canvas/minimap https://cmux.com/docs/changelog | Mitchell H. mention https://x.com/mitchellh/status/2024913161238053296
|
||||
|
||||
### 5. AgentGrid (Product Hunt / X Jul 2026)
|
||||
**Fleet view:** Team of coding agents (builder/QA/devops) on one infinite canvas with orchestrator loop — closest "spawn a squad on a board" demo in recent X.
|
||||
**Links:** X demo https://x.com/yagudaev/status/2078376271864869134 | Product Hunt (via post)
|
||||
|
||||
### 6. OpenHands Agent Canvas
|
||||
**Fleet view:** Browser/desktop control surface for parallel agents in isolated worktrees; conversation + automations + skills/MCP; multi-harness via ACP (Claude Code, Codex, Gemini CLI, OpenHands); local→VM→cloud backends. Visual is session/automation studio more than freeform node map.
|
||||
**Links:** https://www.openhands.dev/product/canvas | https://docs.openhands.dev/openhands/usage/agent-canvas/overview | https://github.com/OpenHands/agent-canvas | ACP blog https://www.openhands.dev/blog/use-any-coding-agent-in-openhands-with-acp | X product https://x.com/OpenHandsDev
|
||||
|
||||
### 7. Cursor Glass / Agents Window (Cursor 3)
|
||||
**Fleet view:** Agent-first full-screen workspace (Glass); parallel local/cloud/SSH agents across repos; editor secondary; cloud handoff mid-task; threads + filters. Visual = agent cards/sessions, not topology map.
|
||||
**Links:** https://cursor.com/docs/agent/agents-window | https://cursor.com/glass | review https://www.digitalapplied.com/blog/cursor-3-agents-window-complete-guide | Addy Osmani landscape https://addyosmani.com/blog/code-agent-orchestra/
|
||||
|
||||
### 8. Conductor (Melty Labs)
|
||||
**Fleet view:** Polished macOS dashboard of parallel Claude Code/Codex/Cursor agents in git worktrees; glance status + diff-first review + checkpoints; Linear start. Visual = multi-agent cards/diff rails (strong), not freeform canvas.
|
||||
**Links:** https://www.conductor.build/ | YC https://www.ycombinator.com/companies/conductor | spotlight https://addyosmani.com/blog/code-agent-orchestra/
|
||||
|
||||
### 9. Weave Agent Fleet
|
||||
**Fleet view:** Local web dashboard (localhost:3000) of OpenCode sessions with status/diffs/streams; parent agents spawn children with callbacks + overlap conflict detection. Visual = multi-session grid/list with live streams.
|
||||
**Links:** https://tryweave.io/fleet/ | https://github.com/pgermishuys/weave-agent-fleet
|
||||
|
||||
### 10. Devin Desktop — Agent Command Center (Cognition)
|
||||
**Fleet view:** Agent Command Center as DEFAULT IDE surface: Kanban-style board of local Devin, cloud Devin, and ACP third-party agents (Codex/Claude/OpenCode); Spaces for shared context; full Windsurf-compat editor underneath. Mission-control language, kanban encoding.
|
||||
**Links:** https://cognition.com/blog/introducing-devin-desktop | https://devin.ai/ | https://devin.ai/download | review https://www.fixedlabs.ai/blog/devin-desktop-review
|
||||
|
||||
### 11. OpenAI Codex app + Codex cloud
|
||||
**Fleet view:** Native macOS/Windows Codex app for multi-agent parallel worktrees, long-running supervision, GitHub PRs; cloud = isolated env job board from web/GitHub/Linear/Slack. Visual = multi-task agent supervisor, not canvas.
|
||||
**Links:** https://openai.com/index/introducing-the-codex-app/ | https://developers.openai.com/codex/app | cloud https://learn.chatgpt.com/docs/cloud
|
||||
|
||||
### 12. GitHub Copilot Agents panel / VS Code Agents window
|
||||
**Fleet view:** Site-wide Agents panel (model picker, cloud coding agent, third-party/custom agents) + VS Code Agents window unifying local/background/cloud/third-party sessions. Visual = mission-control list, not map.
|
||||
**Links:** https://github.com/features/copilot/agents | https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent | https://github.blog/ai-and-ml/github-copilot/whats-new-with-github-copilot-coding-agent/ | VS Code https://code.visualstudio.com/docs/agents/overview | multi-agent blog https://code.visualstudio.com/blogs/2026/02/05/multi-agent-development
|
||||
|
||||
### 13. Claude Code Agent view
|
||||
**Fleet view:** Full-terminal roster (`claude agents`) grouped by state; needs-you + pinned on top; peek/reply without leaving list. Visual = TUI table, excellent state priority, not spatial.
|
||||
**Links:** https://code.claude.com/docs/en/agent-view
|
||||
|
||||
### 14. Herdr
|
||||
**Fleet view:** Rust tmux-class agent multiplexer; sidebar of agents with blocked/working/done; real PTY panes; detach/SSH. Visual = pane grid + state list.
|
||||
**Links:** https://herdr.dev/ | https://herdr.dev/docs/agents/ | https://github.com/herdrdev/herdr | Better Stack https://betterstack.com/community/guides/ai/herdr-ai-agent/
|
||||
|
||||
### 15. claude-squad
|
||||
**Fleet view:** Terminal app managing multi Claude/Codex/Gemini/Aider in workspaces; one window supervise + review. Visual = TUI multi-instance manager.
|
||||
**Links:** https://github.com/smtg-ai/claude-squad | https://smtg-ai.github.io/claude-squad/
|
||||
|
||||
### 16. Vibe Kanban
|
||||
**Fleet view:** Plan → Prompt → Review kanban; worktree workspaces; inline diffs; multi-agent switch; sunsetting commercial / OSS continues. Strong workflow, low canvas score.
|
||||
**Links:** https://vibekanban.com/ | https://github.com/BloopAI/vibe-kanban
|
||||
|
||||
### 17. Factory Droid
|
||||
**Fleet view:** TUI + desktop app with multi-session sidebar; Missions multi-agent; dynamic in-conversation Mermaid/charts (agent-generated viz, not fleet topology).
|
||||
**Links:** https://factory.ai/ | desktop https://factory.ai/news/factory-desktop | docs https://docs.factory.ai/cli/getting-started/how-to-talk-to-a-droid
|
||||
|
||||
### 18. Amp (Sourcegraph)
|
||||
**Fleet view:** Terminal/editor agent with subagents + Task tool; web/phone start; "orb" multiplayer sharing; not a fleet map product.
|
||||
**Links:** https://ampcode.com/ | https://ampcode.com/manual
|
||||
|
||||
### 19. Terragon
|
||||
**Fleet view:** Cloud Claude/Codex/etc background agents + task dashboard/PR flow. Async job board, not canvas.
|
||||
**Links:** https://www.terragonlabs.com/ | https://terragon.devdocs.ai/ | writeup https://ymichael.com/2025/07/15/claude-code-unleashed.html
|
||||
|
||||
### 20. Pebble (Nebutra)
|
||||
**Fleet view:** Appears as Orca-adjacent product port (Nebutra/pebble tracks Upstream Orca renderer semantic ports) — not a distinct public fleet-canvas brand found in web search beyond that lineage.
|
||||
**Links:** https://github.com/Nebutra/pebble (issues show Upstream Orca porting) | compare Orca agents docs https://www.onorca.dev/docs/model/agents-sessions
|
||||
|
||||
### Also-notable newer / adjacent
|
||||
- **Codecast** mobile+web agent inbox: https://codecast.sh/
|
||||
- **Junction Panel** local agent dashboard: https://junctionpanel.dev/ai-coding-agent-dashboard/
|
||||
- **Agent-teams-ai** kanban multi-agent desktop: https://github.com/777genius/agent-teams-ai
|
||||
- **AgentCommand** (MattPRD X) extreme-scale agent-of-agents dashboard: https://x.com/MattPRD/status/2012743002985484479
|
||||
- **Moltbot Agent Canvas UI**: https://x.com/georgepickett/status/2017046827686908246
|
||||
- **Alex @alexyango** agent-native Slack + experimental infinite canvas (Jul 2026): https://x.com/alexyango/status/2079104946700788179
|
||||
- **Dessn Canvas** design agents on infinite canvas: https://x.com/Dessn_ai/status/2077029646961348634
|
||||
- Landscape survey: https://addyosmani.com/blog/code-agent-orchestra/ | https://github.com/andyrewlee/awesome-agent-orchestrators
|
||||
|
||||
---
|
||||
|
||||
## TOP 5 REFERENCES for a minimal modern pan/zoom canvas of repo systems + agent nodes
|
||||
|
||||
1. **OpenCove** — freeform agent/terminal/task nodes on infinite canvas; `@xyflow/react` + xterm; persistent layout; control center. https://github.com/DeadWaveWave/opencove
|
||||
2. **React Flow / xyflow MiniMap + Controls** — production minimap + pan/zoom primitives OpenCove-class UIs build on. https://reactflow.dev/api-reference/components/minimap | https://reactflow.dev/
|
||||
3. **cmux experimental canvas + minimap navigation** — attention rings + minimap overview for multi-workspace agent terminals. https://cmux.com/docs/changelog | https://github.com/manaflow-ai/cmux
|
||||
4. **49Agents** — multi-host panes on one canvas (k8s "nodes across machines" metaphor). https://www.49agents.com/
|
||||
5. **Collaborator** — cleanest minimal tile canvas: double-click terminal, drag context files, grid snap, no cloud lock-in. https://github.com/collabs-inc/collab-public
|
||||
|
||||
### Design takeaways for Orca k8s-style fleet canvas
|
||||
- Prefer **containment hierarchy** (project → worktree → agent) over pure freeform once N>30 (OpenCove freeform is best for 5–20; Orca mocks already do rings/map).
|
||||
- Always ship a **minimap** (xyflow MiniMap pattern; cmux polishing this).
|
||||
- Encode **needs-you** with non-text channels: cmux notification rings; Claude agent-view state sort.
|
||||
- Nodes = agents; clusters = repos/worktrees; edges = orchestration lineage (on-demand only).
|
||||
- Hover → live terminal preview; click → escalate to full PTY (Conductor/OpenCove pattern).
|
||||
- Diff/PR status as node badge (Conductor/Vibe/Codex), not only chat state.
|
||||
|
||||
## Method notes
|
||||
Web search + product pages + GitHub READMEs + X keyword/semantic (~May–Aug 2026). No worktree files modified. worker_done may fail RPC legacy_read_only on this pane; full findings are in this body.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Agent Command Deck
|
||||
|
||||
## Product thesis
|
||||
|
||||
The dashboard has two simultaneous jobs: account for the entire fleet and accelerate
|
||||
senior-developer intervention.
|
||||
|
||||
Every agent remains represented in the command radar, grouped spatially and encoded by
|
||||
state. Labels expand only for meaningful clusters so complete awareness does not become
|
||||
168 competing cards. The actionable unit is a **decision**: each one combines the
|
||||
question, distilled evidence, affected worktrees, recipients, and a persistent response
|
||||
composer. One response can unblock several agents without leaving the dashboard.
|
||||
|
||||
The **Fleet Rings** variant gives spatial placement a concrete meaning:
|
||||
|
||||
- Outer rings contain projects.
|
||||
- Nested rings contain worktrees.
|
||||
- Agent nodes inside each worktree scale logarithmically with active duration.
|
||||
- Color and halo communicate operational state independently from duration.
|
||||
- Hover previews live terminal output; click pins or opens the terminal.
|
||||
- Placement stays stable between updates so developers build spatial memory.
|
||||
|
||||
## Focused Fleet mock
|
||||
|
||||
`agent-rings-focus.html` tests an inbox-oriented operating model:
|
||||
|
||||
- Focus includes Needs You, Working, and finished results still marked To review.
|
||||
- Finished results can be filtered to To review, 24 hours, 7 days, or All.
|
||||
- Opening marks a result seen; only explicit review removes it from Focus.
|
||||
- Pinning keeps an agent visible through review and other state filters.
|
||||
- Reviewed results collapse into workspace summaries at larger time windows.
|
||||
- Duration changes the agent circle itself, not its containing workspace.
|
||||
- Every visible agent keeps a label and a large click target.
|
||||
- The terminal supports continuation, review, pinning, and bulk review from the rail.
|
||||
|
||||
## Interaction model
|
||||
|
||||
1. Open the next intervention.
|
||||
2. Read the distilled disagreement and evidence.
|
||||
3. Respond once to every affected agent.
|
||||
4. Pop up a terminal only when raw context is necessary.
|
||||
5. Close the terminal and return to the preserved response draft.
|
||||
6. Send and advance to the next decision.
|
||||
|
||||
The orchestrator remains globally available for fleet synthesis, search, and ad hoc
|
||||
commands. It is a power tool within the command deck, not the primary navigation model.
|
||||
|
||||
## Product hierarchy
|
||||
|
||||
- **Primary:** Command Radar — complete fleet awareness plus the current intervention.
|
||||
- **Focused:** Needs You — decisions, permissions, and true blockers.
|
||||
- **Operational:** Worktrees — precise filtering, inspection, and bulk action.
|
||||
- **Historical:** History — completed work and retrospective search.
|
||||
|
||||
At high scale, the interface must prove that every agent is accounted for while emphasizing
|
||||
decisions waiting, agents unblocked per response, time to intervention, and work that can
|
||||
continue unattended.
|
||||
@@ -0,0 +1,210 @@
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url("../src/renderer/src/assets/fonts/Geist-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #fff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #fff;
|
||||
--card-foreground: #0a0a0a;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--accent-foreground: #171717;
|
||||
--border: #e5e5e5;
|
||||
--ring: #a1a1a1;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fafafa;
|
||||
--status-success: #15803d;
|
||||
--attention: #f59e0b;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #171717;
|
||||
--card-foreground: #fafafa;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #404040;
|
||||
--accent-foreground: #fafafa;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--ring: #737373;
|
||||
--primary: #e5e5e5;
|
||||
--primary-foreground: #171717;
|
||||
--status-success: #86efac;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; overflow: hidden; background: var(--background); color: var(--foreground); font: 14px/1.45 Geist, sans-serif; letter-spacing: .01em; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { color: inherit; cursor: pointer; }
|
||||
.svg-defs { position: absolute; width: 0; height: 0; }
|
||||
.icon { width: 15px; height: 15px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.eyebrow { color: var(--muted-foreground); font-size: 10px; font-weight: 650; letter-spacing: .055em; text-transform: uppercase; }
|
||||
kbd { padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; background: var(--muted); color: var(--muted-foreground); font-size: 9px; font-weight: 500; white-space: nowrap; }
|
||||
|
||||
.deck-shell { min-width: 1280px; height: 100vh; min-height: 760px; overflow: hidden; background: var(--background); }
|
||||
.topbar { height: 62px; display: flex; align-items: center; gap: 14px; padding: 0 18px; border-bottom: 1px solid var(--border); background: var(--background); }
|
||||
.orca-mark { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 8px; background: var(--primary); color: var(--primary-foreground); font-weight: 800; }
|
||||
.product-title { min-width: 96px; display: flex; flex-direction: column; line-height: 1.15; }
|
||||
.product-title strong { font-size: 16px; }
|
||||
.product-title span { color: var(--muted-foreground); font-size: 10px; }
|
||||
.primary-nav { display: flex; align-items: center; gap: 2px; height: 34px; padding: 3px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.primary-nav button { height: 26px; display: flex; align-items: center; gap: 6px; padding: 0 9px; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); font-size: 10px; }
|
||||
.primary-nav button:hover, .primary-nav button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.primary-nav b { min-width: 17px; padding: 1px 4px; border-radius: 999px; background: color-mix(in srgb, var(--attention) 13%, transparent); color: var(--attention); font-size: 8px; }
|
||||
.primary-nav span { font-size: 8px; }
|
||||
.search { width: min(300px, 21vw); height: 32px; display: flex; align-items: center; gap: 7px; margin-left: auto; padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--muted) 55%, transparent); color: var(--muted-foreground); }
|
||||
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: 11px; }
|
||||
.orchestrator-button { height: 32px; display: flex; align-items: center; gap: 7px; padding: 0 9px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); font-size: 10px; }
|
||||
.orchestrator-button:hover { background: var(--muted); }
|
||||
.orch-dot { width: 7px; height: 7px; border: 1px solid var(--muted-foreground); border-radius: 50%; box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted-foreground) 10%, transparent); }
|
||||
|
||||
.deck-grid { height: calc(100vh - 62px); display: grid; grid-template-columns: 290px minmax(650px, 1fr) 300px; }
|
||||
.intervention-queue, .impact-panel { min-height: 0; background: color-mix(in srgb, var(--card) 68%, var(--background)); }
|
||||
.intervention-queue { display: flex; flex-direction: column; border-right: 1px solid var(--border); }
|
||||
.queue-header { padding: 17px 14px 12px; border-bottom: 1px solid var(--border); }
|
||||
.queue-header > div { display: flex; flex-direction: column; gap: 4px; }
|
||||
.queue-header h1 { margin: 0; font-size: 14px; line-height: 1.35; }
|
||||
.queue-header button { margin-top: 10px; padding: 0; border: 0; background: transparent; color: var(--muted-foreground); font-size: 9px; }
|
||||
.queue-group { padding: 9px 8px 3px; }
|
||||
.group-label { display: flex; align-items: center; justify-content: space-between; padding: 0 6px 5px; color: var(--muted-foreground); font-size: 9px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
|
||||
.group-label b { font-size: 9px; }
|
||||
.queue-item { width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 9px 7px; border: 1px solid transparent; border-radius: 7px; background: transparent; text-align: left; }
|
||||
.queue-item:hover { background: var(--accent); }
|
||||
.queue-item.is-selected { border-color: color-mix(in srgb, var(--attention) 23%, transparent); background: color-mix(in srgb, var(--attention) 7%, transparent); }
|
||||
.state-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted-foreground); opacity: .5; }
|
||||
.state-dot.attention { background: var(--attention); opacity: 1; box-shadow: 0 0 7px color-mix(in srgb, var(--attention) 55%, transparent); }
|
||||
.state-dot.watching { border: 1px solid var(--muted-foreground); background: transparent; opacity: 1; }
|
||||
.queue-copy { min-width: 0; display: flex; flex-direction: column; }
|
||||
.queue-copy strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-copy small, .queue-item time { color: var(--muted-foreground); font-size: 8px; }
|
||||
.queue-group.quiet { opacity: .72; }
|
||||
.queue-footer { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; margin-top: auto; padding: 10px; border-top: 1px solid var(--border); }
|
||||
.queue-footer span { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 6px; border-radius: 6px; background: var(--muted); color: var(--muted-foreground); font-size: 7px; text-transform: uppercase; }
|
||||
.queue-footer i { width: 5px; height: 5px; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.queue-footer strong { color: var(--foreground); font-size: 11px; }
|
||||
.queue-footer .icon { width: 10px; height: 10px; }
|
||||
|
||||
.decision-workspace { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--background); }
|
||||
.decision-header { min-height: 88px; display: flex; align-items: center; gap: 24px; padding: 15px 20px; border-bottom: 1px solid var(--border); }
|
||||
.decision-title { min-width: 0; }
|
||||
.decision-title h2 { margin: 2px 0 1px; font-size: 19px; }
|
||||
.decision-title p { margin: 0; color: var(--muted-foreground); font-size: 10px; }
|
||||
.decision-meta { display: flex; align-items: center; gap: 7px; margin-left: auto; }
|
||||
.decision-meta > span, .terminal-trigger { height: 29px; display: flex; align-items: center; gap: 5px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); color: var(--muted-foreground); font-size: 9px; }
|
||||
.terminal-trigger { color: var(--foreground); }
|
||||
.terminal-trigger:hover { background: var(--muted); }
|
||||
.decision-scroll { min-height: 0; flex: 1; overflow: auto; padding: 18px 20px 16px; scrollbar-width: thin; }
|
||||
.question-card { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: start; gap: 12px; padding: 16px; border: 1px solid color-mix(in srgb, var(--attention) 24%, transparent); border-radius: var(--radius); background: color-mix(in srgb, var(--attention) 6%, transparent); }
|
||||
.question-mark { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--attention) 38%, transparent); border-radius: 50%; color: var(--attention); font-weight: 700; }
|
||||
.question-card h3 { margin: 3px 0 5px; font-size: 14px; }
|
||||
.question-card p { max-width: 720px; margin: 0; color: var(--muted-foreground); font-size: 10px; }
|
||||
.waiting-chip { padding: 3px 7px; border-radius: 999px; background: color-mix(in srgb, var(--attention) 13%, transparent); color: var(--attention); font-size: 8px; font-weight: 650; text-transform: uppercase; }
|
||||
.context-heading { display: flex; align-items: center; justify-content: space-between; margin: 17px 2px 8px; }
|
||||
.context-heading > span:last-child { color: var(--muted-foreground); font-size: 8px; }
|
||||
.context-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||
.context-card { min-height: 132px; display: flex; flex-direction: column; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--card) 84%, transparent); }
|
||||
.context-card header { display: flex; align-items: center; gap: 7px; }
|
||||
.context-card header strong { font-size: 10px; }
|
||||
.state-icon { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 5px; background: var(--muted); color: var(--muted-foreground); font-size: 12px; }
|
||||
.state-icon.agreed { color: var(--status-success); }
|
||||
.state-icon.split { color: var(--attention); }
|
||||
.context-card p { margin: 11px 0 0; color: var(--muted-foreground); font-size: 9px; }
|
||||
.evidence-card button { display: flex; justify-content: space-between; margin-top: auto; padding: 6px 0 0; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--foreground); font-size: 8px; }
|
||||
.routing-card { margin-top: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--card) 65%, transparent); }
|
||||
.routing-card > header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.routing-card > header button { border: 0; background: transparent; color: var(--muted-foreground); font-size: 8px; }
|
||||
.recipient-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 9px; }
|
||||
.recipient-list > div { min-width: 0; display: grid; grid-template-columns: 25px minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 7px; border-radius: 6px; background: var(--muted); }
|
||||
.avatar { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 6px; background: var(--card); font-size: 9px; font-weight: 700; }
|
||||
.recipient-list p { min-width: 0; display: flex; flex-direction: column; margin: 0; }
|
||||
.recipient-list strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recipient-list small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.recipient-list b { color: var(--muted-foreground); font-size: 7px; font-weight: 500; }
|
||||
|
||||
.response-composer { flex: 0 0 auto; margin: 0 16px 14px; overflow: hidden; border: 1px solid var(--ring); border-radius: var(--radius); background: var(--card); box-shadow: 0 10px 24px rgb(0 0 0 / .18); }
|
||||
.composer-topline { height: 34px; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid var(--border); color: var(--muted-foreground); font-size: 9px; }
|
||||
.composer-topline strong { color: var(--foreground); }
|
||||
.response-mode { display: flex; gap: 2px; margin-left: auto; }
|
||||
.response-mode button { padding: 3px 7px; border: 0; border-radius: 4px; background: transparent; color: var(--muted-foreground); font-size: 8px; }
|
||||
.response-mode button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.response-composer textarea { width: 100%; min-height: 74px; display: block; resize: none; padding: 11px; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: 11px; line-height: 1.55; }
|
||||
.composer-footer { min-height: 38px; display: flex; align-items: center; gap: 6px; padding: 6px 8px 6px 11px; border-top: 1px solid var(--border); }
|
||||
.composer-footer > span { margin-right: auto; color: var(--muted-foreground); font-size: 8px; }
|
||||
.secondary-button, .send-button { height: 28px; display: flex; align-items: center; gap: 5px; padding: 0 8px; border-radius: 6px; font-size: 9px; }
|
||||
.secondary-button { border: 1px solid var(--border); background: transparent; }
|
||||
.send-button { border: 1px solid var(--primary); background: var(--primary); color: var(--primary-foreground); }
|
||||
.send-button kbd { border-color: color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); color: inherit; }
|
||||
|
||||
.impact-panel { min-height: 0; overflow: auto; padding: 16px; border-left: 1px solid var(--border); }
|
||||
.impact-panel section { padding: 14px 0; border-bottom: 1px solid var(--border); }
|
||||
.impact-panel section:first-child { padding-top: 0; }
|
||||
.impact-summary { text-align: center; }
|
||||
.impact-number { display: block; margin-top: 7px; font-size: 35px; line-height: 1; }
|
||||
.impact-summary p { margin: 4px 0 9px; color: var(--muted-foreground); font-size: 9px; }
|
||||
.impact-bars { display: flex; justify-content: center; gap: 4px; }
|
||||
.impact-bars i { width: 32px; height: 3px; border-radius: 3px; background: var(--status-success); opacity: .65; }
|
||||
.worktree-impact > span { display: block; margin-bottom: 8px; }
|
||||
.worktree-impact button { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 8px; border: 0; border-radius: 6px; background: transparent; text-align: left; }
|
||||
.worktree-impact button:hover { background: var(--accent); }
|
||||
.worktree-impact button span { min-width: 0; display: flex; flex-direction: column; }
|
||||
.worktree-impact strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.worktree-impact small { color: var(--muted-foreground); font-size: 8px; }
|
||||
.worktree-impact b { color: var(--muted-foreground); font-size: 8px; font-weight: 500; }
|
||||
.live-signals header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||
.live-indicator { display: flex; align-items: center; gap: 4px; color: var(--status-success); font-size: 8px; }
|
||||
.live-indicator i { width: 5px; height: 5px; border-radius: 50%; background: var(--status-success); animation: pulse 1.5s ease-in-out infinite; }
|
||||
.live-signals article { display: grid; grid-template-columns: 5px minmax(0, 1fr) auto; align-items: center; gap: 7px; min-height: 48px; }
|
||||
.live-signals article > i { width: 5px; height: 5px; border-radius: 50%; background: var(--muted-foreground); opacity: .45; }
|
||||
.live-signals p { min-width: 0; display: flex; flex-direction: column; margin: 0; }
|
||||
.live-signals strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.live-signals small, .live-signals time { color: var(--muted-foreground); font-size: 7px; }
|
||||
.next-up p { display: flex; flex-direction: column; margin: 8px 0 4px; }
|
||||
.next-up p strong { font-size: 9px; }
|
||||
.next-up p span, .next-up > span:last-child { color: var(--muted-foreground); font-size: 8px; }
|
||||
|
||||
.terminal-backdrop { position: fixed; z-index: 20; inset: 62px 0 0; display: none; align-items: center; justify-content: center; padding: 38px; background: rgb(0 0 0 / .48); backdrop-filter: blur(2px); }
|
||||
.terminal-backdrop.is-open { display: flex; animation: fade-in .14s ease-out; }
|
||||
.terminal-popover { width: min(960px, 76vw); height: min(610px, 72vh); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--ring); border-radius: 12px; background: var(--background); color: var(--primary); box-shadow: 0 10px 24px rgb(0 0 0 / .18); }
|
||||
.terminal-popover > header { height: 47px; display: flex; align-items: center; gap: 5px; padding: 0 9px 0 14px; border-bottom: 1px solid var(--border); }
|
||||
.terminal-popover > header div { min-width: 0; display: grid; grid-template-columns: 8px auto; align-items: center; gap: 0 7px; margin-right: auto; }
|
||||
.terminal-status { width: 7px; height: 7px; grid-row: span 2; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.terminal-popover > header strong { font-size: 10px; }
|
||||
.terminal-popover > header small { color: var(--muted-foreground); font-size: 8px; }
|
||||
.terminal-popover > header button { width: 29px; height: 29px; display: grid; place-items: center; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); }
|
||||
.terminal-popover > header button:hover { background: var(--muted); color: var(--foreground); }
|
||||
.terminal-tabs { height: 34px; display: flex; align-items: center; gap: 2px; padding: 0 10px; border-bottom: 1px solid var(--border); }
|
||||
.terminal-tabs button { height: 24px; padding: 0 9px; border: 0; border-radius: 4px; background: transparent; color: var(--muted-foreground); font-size: 8px; }
|
||||
.terminal-tabs button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.terminal-tabs span { margin-left: auto; color: var(--status-success); font-size: 8px; text-transform: uppercase; }
|
||||
.terminal-output { flex: 1; margin: 0; padding: 26px; overflow: auto; color: var(--primary); font: 12px/1.8 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
|
||||
.terminal-output .muted { color: var(--ring); }
|
||||
.terminal-output .prompt { color: var(--status-success); }
|
||||
.terminal-output .code { padding: 2px 4px; border-radius: 3px; background: var(--muted); color: var(--foreground); }
|
||||
.terminal-output .attention { color: var(--attention); }
|
||||
.terminal-popover > footer { height: 45px; display: flex; align-items: center; padding: 0 10px 0 14px; border-top: 1px solid var(--border); }
|
||||
.terminal-popover > footer > span { margin-right: auto; color: var(--ring); font-size: 8px; }
|
||||
.terminal-popover > footer button { height: 28px; display: flex; align-items: center; gap: 7px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); color: var(--foreground); font-size: 9px; }
|
||||
.sent-toast { position: fixed; z-index: 30; right: 18px; bottom: 18px; display: flex; align-items: center; gap: 9px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--status-success) 25%, transparent); border-radius: 8px; background: var(--card); box-shadow: 0 10px 24px rgb(0 0 0 / .18); opacity: 0; pointer-events: none; transform: translateY(8px); transition: .16s ease-out; }
|
||||
.sent-toast.is-visible { opacity: 1; transform: translateY(0); }
|
||||
.sent-toast .icon { color: var(--status-success); }
|
||||
.sent-toast span { display: flex; flex-direction: column; color: var(--muted-foreground); font-size: 8px; }
|
||||
.sent-toast strong { color: var(--foreground); font-size: 9px; }
|
||||
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
@keyframes pulse { 50% { opacity: .3; } }
|
||||
@media (max-width: 1400px) {
|
||||
.deck-grid { grid-template-columns: 265px minmax(620px, 1fr) 270px; }
|
||||
.primary-nav button { padding: 0 7px; }
|
||||
.search { width: 220px; }
|
||||
.context-card { min-height: 145px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Agent Command Deck</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./agent-command-deck.css" />
|
||||
</head>
|
||||
<body class="dark">
|
||||
<svg class="svg-defs" aria-hidden="true">
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></symbol>
|
||||
<symbol id="i-command" viewBox="0 0 24 24"><path d="M9 6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3z"></path></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><path d="m4 6 5 5-5 5M12 18h8"></path></symbol>
|
||||
<symbol id="i-git" viewBox="0 0 24 24"><circle cx="6" cy="5" r="2"></circle><circle cx="18" cy="6" r="2"></circle><circle cx="6" cy="19" r="2"></circle><path d="M6 7v10M8 9c5 0 4-3 8-3"></path></symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.9"></path></symbol>
|
||||
<symbol id="i-zap" viewBox="0 0 24 24"><path d="m13 2-9 12h8l-1 8 9-12h-8z"></path></symbol>
|
||||
<symbol id="i-expand" viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"></path></symbol>
|
||||
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"></path></symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4zM22 2 11 13"></path></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"></path></symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"></path><circle cx="12" cy="12" r="3"></circle></symbol>
|
||||
</svg>
|
||||
|
||||
<main class="deck-shell">
|
||||
<header class="topbar">
|
||||
<div class="orca-mark">O</div>
|
||||
<div class="product-title"><strong>Agents</strong><span>Command deck</span></div>
|
||||
<nav class="primary-nav" aria-label="Agent dashboard sections">
|
||||
<button class="is-active">Needs you <b>9</b></button>
|
||||
<button>Fleet <span>168</span></button>
|
||||
<button>Worktrees <span>104</span></button>
|
||||
<button>History</button>
|
||||
</nav>
|
||||
<label class="search">
|
||||
<svg class="icon"><use href="#i-search"></use></svg>
|
||||
<input aria-label="Search agents and worktrees" placeholder="Find any agent or worktree…" />
|
||||
<kbd data-mac="⌘ K" data-other="Ctrl+K">⌘ K</kbd>
|
||||
</label>
|
||||
<button class="orchestrator-button">
|
||||
<span class="orch-dot"></span>
|
||||
Ask Orchestrator
|
||||
<kbd data-mac="⌘ ⇧ O" data-other="Ctrl+Shift+O">⌘ ⇧ O</kbd>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="deck-grid">
|
||||
<aside class="intervention-queue">
|
||||
<header class="queue-header">
|
||||
<div><span class="eyebrow">Intervention queue</span><h1>Four decisions unblock nine agents</h1></div>
|
||||
<button>Oldest first</button>
|
||||
</header>
|
||||
<section class="queue-group">
|
||||
<div class="group-label"><span>Judgment</span><b>4</b></div>
|
||||
<button class="queue-item is-selected">
|
||||
<span class="state-dot attention"></span>
|
||||
<span class="queue-copy"><strong>Runtime cleanup behavior</strong><small>3 agents · 2 worktrees</small></span>
|
||||
<time>now</time>
|
||||
</button>
|
||||
<button class="queue-item">
|
||||
<span class="state-dot attention"></span>
|
||||
<span class="queue-copy"><strong>Duplicate mobile sessions</strong><small>2 agents · mobile-9717</small></span>
|
||||
<time>3m</time>
|
||||
</button>
|
||||
<button class="queue-item">
|
||||
<span class="state-dot"></span>
|
||||
<span class="queue-copy"><strong>Review scope changed</strong><small>1 agent · pr-9190</small></span>
|
||||
<time>8m</time>
|
||||
</button>
|
||||
</section>
|
||||
<section class="queue-group">
|
||||
<div class="group-label"><span>Access</span><b>2</b></div>
|
||||
<button class="queue-item">
|
||||
<span class="state-dot"></span>
|
||||
<span class="queue-copy"><strong>Trust dialog permission</strong><small>1 agent · desktop test</small></span>
|
||||
<time>7m</time>
|
||||
</button>
|
||||
</section>
|
||||
<section class="queue-group quiet">
|
||||
<div class="group-label"><span>Watchlist</span><b>3</b></div>
|
||||
<button class="queue-item">
|
||||
<span class="state-dot watching"></span>
|
||||
<span class="queue-copy"><strong>Resume matrix running long</strong><small>47m · output 2m ago</small></span>
|
||||
<time>watch</time>
|
||||
</button>
|
||||
</section>
|
||||
<footer class="queue-footer">
|
||||
<span><i></i><strong>34</strong> working</span>
|
||||
<span><i></i><strong>71</strong> unread</span>
|
||||
<span><svg class="icon"><use href="#i-terminal"></use></svg><strong>332</strong> terminals</span>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<section class="decision-workspace">
|
||||
<header class="decision-header">
|
||||
<div class="decision-title">
|
||||
<span class="eyebrow">Decision 1 of 4</span>
|
||||
<h2>Runtime cleanup behavior</h2>
|
||||
<p>One response will route to three waiting agents.</p>
|
||||
</div>
|
||||
<div class="decision-meta">
|
||||
<span><svg class="icon"><use href="#i-users"></use></svg>3 agents</span>
|
||||
<span><svg class="icon"><use href="#i-git"></use></svg>2 worktrees</span>
|
||||
<button class="terminal-trigger" data-open-terminal><svg class="icon"><use href="#i-terminal"></use></svg>Inspect terminal</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="decision-scroll">
|
||||
<section class="question-card">
|
||||
<span class="question-mark">?</span>
|
||||
<div>
|
||||
<span class="eyebrow">The decision</span>
|
||||
<h3>Should stale local agent rows survive an Orca restart?</h3>
|
||||
<p>The implementation is complete. Reviewers disagree only on whether historical local rows remain visible after the backing terminal disappears.</p>
|
||||
</div>
|
||||
<span class="waiting-chip">3 waiting</span>
|
||||
</section>
|
||||
|
||||
<div class="context-heading">
|
||||
<span class="eyebrow">Distilled context</span>
|
||||
<span>Generated from 3 agent threads · refreshed 36s ago</span>
|
||||
</div>
|
||||
<section class="context-grid">
|
||||
<article class="context-card">
|
||||
<header><span class="state-icon agreed"><svg class="icon"><use href="#i-check"></use></svg></span><strong>Everyone agrees</strong></header>
|
||||
<p>Remote and WSL history must survive relay reconnects. Active terminal rows must never be duplicated.</p>
|
||||
</article>
|
||||
<article class="context-card">
|
||||
<header><span class="state-icon split">↔</span><strong>The disagreement</strong></header>
|
||||
<p>Keeping local history aids recovery; dropping it avoids presenting terminal state that no longer exists.</p>
|
||||
</article>
|
||||
<article class="context-card evidence-card">
|
||||
<header><span class="state-icon"><svg class="icon"><use href="#i-eye"></use></svg></span><strong>Evidence</strong></header>
|
||||
<p>418 tests pass. One restart fixture changes depending on the retention policy.</p>
|
||||
<button>View affected diff <span>›</span></button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="routing-card">
|
||||
<header><span class="eyebrow">Your response goes to</span><button>Edit recipients</button></header>
|
||||
<div class="recipient-list">
|
||||
<div><span class="avatar">R</span><p><strong>Review coordinator</strong><small>review-11464 · waiting</small></p><b>primary</b></div>
|
||||
<div><span class="avatar">S</span><p><strong>Security review</strong><small>pr-8783 · waiting</small></p><b>context</b></div>
|
||||
<div><span class="avatar">X</span><p><strong>Cross-platform pass</strong><small>pr-8783 · waiting</small></p><b>context</b></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<form class="response-composer" id="response-form">
|
||||
<div class="composer-topline">
|
||||
<span><strong>Respond once</strong> · route to 3 agents</span>
|
||||
<div class="response-mode"><button type="button" class="is-active">Direction</button><button type="button">Question</button><button type="button">Note</button></div>
|
||||
</div>
|
||||
<textarea id="response-text" aria-label="Response to waiting agents">Drop stale local rows after restart. Preserve remote and WSL history because reconnect can restore their backing sessions. Add the restart fixture for both paths.</textarea>
|
||||
<div class="composer-footer">
|
||||
<span>Agents receive the decision, distilled context, and original thread links.</span>
|
||||
<button type="button" class="secondary-button" data-open-terminal><svg class="icon"><use href="#i-terminal"></use></svg>Terminal</button>
|
||||
<button class="send-button"><svg class="icon"><use href="#i-send"></use></svg>Send & next <kbd data-mac="⌘ ↵" data-other="Ctrl+Enter">⌘ ↵</kbd></button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="impact-panel">
|
||||
<section class="impact-summary">
|
||||
<span class="eyebrow">Expected impact</span>
|
||||
<strong class="impact-number">3</strong>
|
||||
<p>agents unblocked by this response</p>
|
||||
<div class="impact-bars"><i></i><i></i><i></i></div>
|
||||
</section>
|
||||
<section class="worktree-impact">
|
||||
<span class="eyebrow">Affected worktrees</span>
|
||||
<button><span><strong>review-11464</strong><small>1 waiting · 2 working</small></span><b>Open</b></button>
|
||||
<button><span><strong>pr-8783-internal-review</strong><small>2 waiting · 5 working</small></span><b>Open</b></button>
|
||||
</section>
|
||||
<section class="live-signals">
|
||||
<header><span class="eyebrow">While you decide</span><span class="live-indicator"><i></i>live</span></header>
|
||||
<article><i></i><p><strong>Test verification finished</strong><small>418 passed · review-11464</small></p><time>22s</time></article>
|
||||
<article><i></i><p><strong>Mobile agent resumed</strong><small>mobile-9717-dup-sessions</small></p><time>1m</time></article>
|
||||
<article><i></i><p><strong>Review branch converged</strong><small>pr-9190-internal-review</small></p><time>3m</time></article>
|
||||
</section>
|
||||
<section class="next-up">
|
||||
<span class="eyebrow">Next up</span>
|
||||
<p><strong>Duplicate mobile sessions</strong><span>2 agents · decision</span></p>
|
||||
<span>Estimated context: 45 seconds</span>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="terminal-backdrop" id="terminal-backdrop" aria-hidden="true">
|
||||
<section class="terminal-popover" role="dialog" aria-modal="true" aria-label="Agent terminal">
|
||||
<header>
|
||||
<div><span class="terminal-status"></span><strong>Review coordinator</strong><small>review-11464 · agent terminal</small></div>
|
||||
<button aria-label="Expand terminal"><svg class="icon"><use href="#i-expand"></use></svg></button>
|
||||
<button aria-label="Close terminal" data-close-terminal><svg class="icon"><use href="#i-close"></use></svg></button>
|
||||
</header>
|
||||
<div class="terminal-tabs"><button class="is-active">Agent</button><button>Tests</button><button>Git</button><span>live</span></div>
|
||||
<pre class="terminal-output"><span class="muted">Review complete. One product decision remains.</span>
|
||||
|
||||
<span class="prompt">●</span> All reviewers agree remote/WSL history should persist.
|
||||
<span class="prompt">●</span> Local terminal <span class="code">term_83ca</span> no longer exists after restart.
|
||||
|
||||
The remaining choice is whether to retain its historical agent row.
|
||||
Dropping it is consistent with the existing local cleanup contract.
|
||||
|
||||
<span class="attention">Waiting for your direction…</span></pre>
|
||||
<footer>
|
||||
<span>Dashboard response is preserved behind this window.</span>
|
||||
<button data-close-terminal>Return to decision <kbd>Esc</kbd></button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="sent-toast" id="sent-toast"><svg class="icon"><use href="#i-check"></use></svg><span><strong>Direction sent to 3 agents</strong>Opening the next decision…</span></div>
|
||||
<script src="./agent-command-deck.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const terminalBackdrop = document.querySelector('#terminal-backdrop')
|
||||
const responseForm = document.querySelector('#response-form')
|
||||
const sentToast = document.querySelector('#sent-toast')
|
||||
|
||||
document.querySelectorAll('[data-mac][data-other]').forEach((label) => {
|
||||
label.textContent = isMac ? label.dataset.mac : label.dataset.other
|
||||
})
|
||||
|
||||
function setTerminalOpen(open) {
|
||||
if (!terminalBackdrop) return
|
||||
terminalBackdrop.classList.toggle('is-open', open)
|
||||
terminalBackdrop.setAttribute('aria-hidden', String(!open))
|
||||
if (open) terminalBackdrop.querySelector('[data-close-terminal]')?.focus()
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-open-terminal]').forEach((button) => {
|
||||
button.addEventListener('click', () => setTerminalOpen(true))
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-close-terminal]').forEach((button) => {
|
||||
button.addEventListener('click', () => setTerminalOpen(false))
|
||||
})
|
||||
|
||||
terminalBackdrop?.addEventListener('click', (event) => {
|
||||
if (event.target === terminalBackdrop) setTerminalOpen(false)
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && terminalBackdrop?.classList.contains('is-open')) {
|
||||
setTerminalOpen(false)
|
||||
}
|
||||
const usesPlatformModifier = isMac ? event.metaKey : event.ctrlKey
|
||||
if (event.key === 'Enter' && usesPlatformModifier && !terminalBackdrop?.classList.contains('is-open')) {
|
||||
responseForm?.requestSubmit()
|
||||
}
|
||||
})
|
||||
|
||||
responseForm?.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
sentToast?.classList.add('is-visible')
|
||||
window.setTimeout(() => sentToast?.classList.remove('is-visible'), 2200)
|
||||
})
|
||||
|
||||
document.querySelectorAll('.response-mode button').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.response-mode button').forEach((item) => item.classList.remove('is-active'))
|
||||
button.classList.add('is-active')
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('.queue-item').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.queue-item').forEach((item) => item.classList.remove('is-selected'))
|
||||
button.classList.add('is-selected')
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 177 KiB |
@@ -0,0 +1,660 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Agent Rings — Focused Fleet</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./agent-rings-focus.css" />
|
||||
</head>
|
||||
<body class="dark">
|
||||
<svg class="svg-defs" aria-hidden="true">
|
||||
<symbol id="i-board" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="7" height="18" rx="1"></rect>
|
||||
<rect x="14" y="3" width="7" height="11" rx="1"></rect>
|
||||
</symbol>
|
||||
<symbol id="i-orbit" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M4.5 9.5C2.8 14.8 6.2 20 12 20c5.5 0 9-4.7 7.8-9.8"></path>
|
||||
<path d="M19.5 14.5C21.2 9.2 17.8 4 12 4 6.5 4 3 8.7 4.2 13.8"></path>
|
||||
</symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24">
|
||||
<path d="M4 5h16M7 12h10M10 19h4"></path>
|
||||
</symbol>
|
||||
<symbol id="i-focus" viewBox="0 0 24 24">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3"></path>
|
||||
<path d="M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</symbol>
|
||||
<symbol id="i-chevron" viewBox="0 0 24 24">
|
||||
<path d="m9 18 6-6-6-6"></path>
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24">
|
||||
<path d="m5 12 4 4L19 6"></path>
|
||||
</symbol>
|
||||
<symbol id="i-circle-check" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="m9 12 2 2 4-4"></path>
|
||||
</symbol>
|
||||
<symbol id="i-message-question" viewBox="0 0 24 24">
|
||||
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z"></path>
|
||||
<path d="M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4"></path>
|
||||
<path d="M12 17h.01"></path>
|
||||
</symbol>
|
||||
<symbol id="i-close" viewBox="0 0 24 24">
|
||||
<path d="m6 6 12 12M18 6 6 18"></path>
|
||||
</symbol>
|
||||
<symbol id="i-external" viewBox="0 0 24 24">
|
||||
<path d="M15 3h6v6M10 14 21 3"></path>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
</symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24">
|
||||
<path d="m4 6 5 5-5 5M12 18h8"></path>
|
||||
</symbol>
|
||||
<symbol id="i-bot" viewBox="0 0 24 24">
|
||||
<rect x="4" y="7" width="16" height="12" rx="3"></rect>
|
||||
<path d="M12 3v4M8 12h.01M16 12h.01M9 16h6"></path>
|
||||
</symbol>
|
||||
<symbol id="i-pin" viewBox="0 0 24 24">
|
||||
<path d="m12 17v5M5 17h14l-3-5V5l2-2H6l2 2v7z"></path>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<main class="app-shell" data-mode="inbox">
|
||||
<header class="titlebar">
|
||||
<div class="window-controls" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||
<h1>Orca Agent Dashboard</h1>
|
||||
</header>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="app-title">
|
||||
<strong>Agents</strong>
|
||||
<span>196 total</span>
|
||||
</div>
|
||||
<nav class="view-switcher" aria-label="Dashboard view">
|
||||
<button>
|
||||
<svg class="icon"><use href="#i-board"></use></svg>
|
||||
Dashboard
|
||||
</button>
|
||||
<button class="is-active">
|
||||
<svg class="icon"><use href="#i-orbit"></use></svg>
|
||||
Agent Rings
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="toolbar">
|
||||
<label class="search">
|
||||
<svg class="icon"><use href="#i-search"></use></svg>
|
||||
<input id="agent-search" placeholder="Search workspace, project, or agent…" />
|
||||
<kbd data-mac="⌘ K" data-other="Ctrl+K">⌘ K</kbd>
|
||||
</label>
|
||||
<button class="toolbar-filter">
|
||||
<svg class="icon"><use href="#i-filter"></use></svg>
|
||||
Filter
|
||||
<svg class="icon chevron"><use href="#i-chevron"></use></svg>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="fleet-layout">
|
||||
<aside class="filter-rail">
|
||||
<header>
|
||||
<span class="eyebrow">Focus view</span>
|
||||
<h2>You decide what stays visible</h2>
|
||||
<p>Opening a result marks it seen. Only Mark reviewed removes it from Focus.</p>
|
||||
</header>
|
||||
|
||||
<section class="state-filters">
|
||||
<span class="eyebrow">Show agent states</span>
|
||||
<button
|
||||
class="state-filter is-active"
|
||||
data-state-toggle="attention"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<span class="state-mark waiting">
|
||||
<svg class="icon"><use href="#i-message-question"></use></svg>
|
||||
</span>
|
||||
<span>Needs you</span>
|
||||
<b>0</b>
|
||||
</button>
|
||||
<button class="state-filter is-active" data-state-toggle="working" aria-pressed="true">
|
||||
<span class="state-mark working"></span>
|
||||
<span>Working</span>
|
||||
<b id="working-count">7</b>
|
||||
</button>
|
||||
<button class="state-filter is-active" data-state-toggle="finished" aria-pressed="true">
|
||||
<span class="state-mark done">
|
||||
<svg class="icon"><use href="#i-circle-check"></use></svg>
|
||||
</span>
|
||||
<span>Finished</span>
|
||||
<b id="finished-count">189</b>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="finished-scopes">
|
||||
<span class="eyebrow">Finished results</span>
|
||||
<button class="scope-filter is-active" data-finished-scope="review">
|
||||
<span>To review</span>
|
||||
<b id="review-count">8</b>
|
||||
</button>
|
||||
<button class="scope-filter" data-finished-scope="day">
|
||||
<span>Last 24 hours</span>
|
||||
<b id="day-count">34</b>
|
||||
</button>
|
||||
<button class="scope-filter" data-finished-scope="week">
|
||||
<span>Last 7 days</span>
|
||||
<b id="week-count">112</b>
|
||||
</button>
|
||||
<button class="scope-filter" data-finished-scope="all">
|
||||
<span>All finished</span>
|
||||
<b id="all-finished-count">189</b>
|
||||
</button>
|
||||
<p>
|
||||
<svg class="icon"><use href="#i-pin"></use></svg>Pinned agents always remain visible.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="project-filters">
|
||||
<span class="eyebrow">Project</span>
|
||||
<button
|
||||
class="project-filter is-active"
|
||||
data-project-toggle="local"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<span>
|
||||
<strong>orca</strong>
|
||||
<small>87 workspaces</small>
|
||||
</span>
|
||||
<b><em>12 focus</em> · 163</b>
|
||||
</button>
|
||||
<button
|
||||
class="project-filter is-active"
|
||||
data-project-toggle="remote"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<span>
|
||||
<strong>orca · remote</strong>
|
||||
<small>7 workspaces</small>
|
||||
</span>
|
||||
<b><em>3 focus</em> · 33</b>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="filter-summary">
|
||||
<strong id="visible-summary">15 of 196 shown</strong>
|
||||
<button id="show-reviewed">181 reviewed results hidden</button>
|
||||
</div>
|
||||
|
||||
<div class="bulk-actions">
|
||||
<button id="review-visible">
|
||||
<svg class="icon"><use href="#i-check"></use></svg>
|
||||
Mark visible results reviewed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<button id="clear-filters">Clear filters</button>
|
||||
<button>
|
||||
<svg class="icon"><use href="#i-focus"></use></svg>
|
||||
Fit visible
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-column">
|
||||
<header class="canvas-header">
|
||||
<div>
|
||||
<span class="eyebrow">Live containment map</span>
|
||||
<strong id="canvas-title">Focus · 15 of 196 agents</strong>
|
||||
</div>
|
||||
<button class="hidden-summary" id="hidden-summary">
|
||||
<svg class="icon"><use href="#i-check"></use></svg>
|
||||
<span id="hidden-summary-copy">181 earlier results hidden</span>
|
||||
</button>
|
||||
<div class="host-switcher" role="group" aria-label="Host filter">
|
||||
<button class="is-active" data-host-filter="all">All hosts</button>
|
||||
<button data-host-filter="local">Local</button>
|
||||
<button data-host-filter="remote">Remote</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rings-canvas" id="rings-canvas">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<span class="empty-orbit"><i></i></span>
|
||||
<strong>No agents need attention</strong>
|
||||
<p>Turn on Working or Finished, or broaden the finished-result window.</p>
|
||||
<button id="reset-empty-state">Reset Focus</button>
|
||||
</div>
|
||||
|
||||
<section class="project-ring project-primary" data-project-key="local">
|
||||
<header class="project-label">
|
||||
<strong>orca</strong>
|
||||
<span data-open-label>6 working · 6 new results · 151 earlier</span>
|
||||
</header>
|
||||
|
||||
<article class="workspace-ring workspace-large">
|
||||
<header>
|
||||
<strong>albacore</strong>
|
||||
<span>2 working · 1 finished</span>
|
||||
</header>
|
||||
<button
|
||||
class="agent-node agent-xl"
|
||||
style="--x: 29%; --y: 40%"
|
||||
data-agent="Cross-platform reviewer"
|
||||
data-workspace="albacore"
|
||||
data-duration="52m"
|
||||
data-agent-status="working"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Cross-platform reviewer</span>
|
||||
<small>52m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-md"
|
||||
style="--x: 71%; --y: 42%"
|
||||
data-agent="Test auditor"
|
||||
data-workspace="albacore"
|
||||
data-duration="18m"
|
||||
data-agent-status="working"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Test auditor</span>
|
||||
<small>18m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-lg"
|
||||
style="--x: 50%; --y: 72%"
|
||||
data-agent="Layout implementation"
|
||||
data-workspace="albacore"
|
||||
data-duration="2m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Layout implementation</span>
|
||||
<small>2m</small>
|
||||
</button>
|
||||
<button
|
||||
class="done-cluster"
|
||||
style="--x: 53%; --y: 91%"
|
||||
data-day-count="8"
|
||||
data-week-count="35"
|
||||
data-all-count="63"
|
||||
>
|
||||
<span class="dot-stack"><i></i><i></i><i></i></span>
|
||||
<strong>63 earlier</strong>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="workspace-ring workspace-top">
|
||||
<header>
|
||||
<strong>orca-trust-dialog</strong>
|
||||
<span>1 working · 2 finished</span>
|
||||
</header>
|
||||
<button
|
||||
class="agent-node agent-lg"
|
||||
style="--x: 50%; --y: 29%"
|
||||
data-agent="Permission flow"
|
||||
data-workspace="orca-trust-dialog"
|
||||
data-duration="37m"
|
||||
data-agent-status="working"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Permission flow</span>
|
||||
<small>37m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-sm"
|
||||
style="--x: 30%; --y: 70%"
|
||||
data-agent="Dialog review"
|
||||
data-workspace="orca-trust-dialog"
|
||||
data-duration="4m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Dialog review</span>
|
||||
<small>4m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-sm"
|
||||
style="--x: 70%; --y: 70%"
|
||||
data-agent="Accessibility pass"
|
||||
data-workspace="orca-trust-dialog"
|
||||
data-duration="7m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Accessibility pass</span>
|
||||
<small>7m</small>
|
||||
</button>
|
||||
<button
|
||||
class="done-cluster"
|
||||
style="--x: 50%; --y: 94%"
|
||||
data-day-count="3"
|
||||
data-week-count="11"
|
||||
data-all-count="19"
|
||||
>
|
||||
<span class="dot-stack"><i></i><i></i><i></i></span>
|
||||
<strong>19 earlier</strong>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="workspace-ring workspace-mid">
|
||||
<header>
|
||||
<strong>dashboard-popout</strong>
|
||||
<span>1 working · 1 finished</span>
|
||||
</header>
|
||||
<button
|
||||
class="agent-node agent-sm"
|
||||
style="--x: 50%; --y: 29%"
|
||||
data-agent="Popover polish"
|
||||
data-workspace="dashboard-popout"
|
||||
data-duration="6m"
|
||||
data-agent-status="working"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Popover polish</span>
|
||||
<small>6m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-sm"
|
||||
style="--x: 50%; --y: 72%"
|
||||
data-agent="Terminal parity"
|
||||
data-workspace="dashboard-popout"
|
||||
data-duration="8m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Terminal parity</span>
|
||||
<small>8m</small>
|
||||
</button>
|
||||
<button
|
||||
class="done-cluster"
|
||||
style="--x: 50%; --y: 95%"
|
||||
data-day-count="5"
|
||||
data-week-count="17"
|
||||
data-all-count="28"
|
||||
>
|
||||
<span class="dot-stack"><i></i><i></i><i></i></span>
|
||||
<strong>28 earlier</strong>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="workspace-ring workspace-bottom">
|
||||
<header>
|
||||
<strong>terminal-recovery</strong>
|
||||
<span>1 working · 1 finished</span>
|
||||
</header>
|
||||
<button
|
||||
class="agent-node agent-md"
|
||||
style="--x: 50%; --y: 29%"
|
||||
data-agent="SSH recovery"
|
||||
data-workspace="terminal-recovery"
|
||||
data-duration="24m"
|
||||
data-agent-status="working"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">SSH recovery</span>
|
||||
<small>24m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-sm"
|
||||
style="--x: 50%; --y: 72%"
|
||||
data-agent="SSH matrix"
|
||||
data-workspace="terminal-recovery"
|
||||
data-duration="12m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">SSH matrix</span>
|
||||
<small>12m</small>
|
||||
</button>
|
||||
<button
|
||||
class="done-cluster"
|
||||
style="--x: 50%; --y: 95%"
|
||||
data-day-count="2"
|
||||
data-week-count="10"
|
||||
data-all-count="17"
|
||||
>
|
||||
<span class="dot-stack"><i></i><i></i><i></i></span>
|
||||
<strong>17 earlier</strong>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article class="workspace-ring workspace-small">
|
||||
<header>
|
||||
<strong>git-compat</strong>
|
||||
<span>1 working · 1 finished</span>
|
||||
</header>
|
||||
<button
|
||||
class="agent-node agent-lg"
|
||||
style="--x: 50%; --y: 29%"
|
||||
data-agent="Git 2.25 audit"
|
||||
data-workspace="git-compat"
|
||||
data-duration="41m"
|
||||
data-agent-status="working"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Git 2.25 audit</span>
|
||||
<small>41m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-sm"
|
||||
style="--x: 50%; --y: 72%"
|
||||
data-agent="Compatibility notes"
|
||||
data-workspace="git-compat"
|
||||
data-duration="18m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Compatibility notes</span>
|
||||
<small>18m</small>
|
||||
</button>
|
||||
<button
|
||||
class="done-cluster"
|
||||
style="--x: 50%; --y: 95%"
|
||||
data-day-count="4"
|
||||
data-week-count="14"
|
||||
data-all-count="24"
|
||||
>
|
||||
<span class="dot-stack"><i></i><i></i><i></i></span>
|
||||
<strong>24 earlier</strong>
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="project-ring project-secondary" data-project-key="remote">
|
||||
<header class="project-label">
|
||||
<strong>orca · remote</strong>
|
||||
<span>1 working · 2 new results · 30 earlier</span>
|
||||
</header>
|
||||
<article class="workspace-ring workspace-remote">
|
||||
<header>
|
||||
<strong>relay-session</strong>
|
||||
<span>1 working · 2 finished</span>
|
||||
</header>
|
||||
<button
|
||||
class="agent-node agent-xl"
|
||||
style="--x: 50%; --y: 27%"
|
||||
data-agent="Relay reconnect"
|
||||
data-workspace="relay-session"
|
||||
data-duration="58m"
|
||||
data-agent-status="working"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Relay reconnect</span>
|
||||
<small>58m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-sm"
|
||||
style="--x: 30%; --y: 70%"
|
||||
data-agent="Reconnect audit"
|
||||
data-workspace="relay-session"
|
||||
data-duration="3m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Reconnect audit</span>
|
||||
<small>3m</small>
|
||||
</button>
|
||||
<button
|
||||
class="agent-node agent-finished agent-sm"
|
||||
style="--x: 70%; --y: 70%"
|
||||
data-agent="Log review"
|
||||
data-workspace="relay-session"
|
||||
data-duration="22m ago"
|
||||
data-agent-status="finished"
|
||||
data-review-state="unreviewed"
|
||||
data-seen="false"
|
||||
>
|
||||
<span class="agent-avatar"
|
||||
><svg class="icon"><use href="#i-bot"></use></svg
|
||||
></span>
|
||||
<span class="agent-name">Log review</span>
|
||||
<small>22m</small>
|
||||
</button>
|
||||
<button
|
||||
class="done-cluster"
|
||||
style="--x: 50%; --y: 92%"
|
||||
data-day-count="4"
|
||||
data-week-count="17"
|
||||
data-all-count="30"
|
||||
>
|
||||
<span class="dot-stack"><i></i><i></i><i></i></span>
|
||||
<strong>30 earlier</strong>
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="done-mode-note">
|
||||
<strong>New results stay visible; earlier results are summarized</strong>
|
||||
<span>Choose an agent to continue it, or a summary to browse older results.</span>
|
||||
</div>
|
||||
|
||||
<div class="done-inspector" id="done-inspector">
|
||||
<header>
|
||||
<div>
|
||||
<span class="eyebrow">Earlier results</span>
|
||||
<strong id="done-workspace-name">albacore</strong>
|
||||
</div>
|
||||
<span id="done-count-label">64 agents</span>
|
||||
<button id="close-done-inspector" aria-label="Close completed agents">
|
||||
<svg class="icon"><use href="#i-close"></use></svg>
|
||||
</button>
|
||||
</header>
|
||||
<div class="done-agent-grid" id="done-agent-grid"></div>
|
||||
<footer>Select an agent to reopen its terminal conversation</footer>
|
||||
</div>
|
||||
|
||||
<div class="zoom-controls">
|
||||
<button aria-label="Zoom out">−</button>
|
||||
<span>100%</span>
|
||||
<button aria-label="Zoom in">+</button>
|
||||
<button>
|
||||
<svg class="icon"><use href="#i-focus"></use></svg> Fit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<button class="orchestrate">
|
||||
<svg class="icon"><use href="#i-bot"></use></svg>
|
||||
Orchestrate
|
||||
<i></i>
|
||||
</button>
|
||||
</main>
|
||||
|
||||
<div class="terminal-overlay" id="terminal-overlay" aria-hidden="true">
|
||||
<section class="terminal-dialog" role="dialog" aria-modal="true" aria-label="Agent terminal">
|
||||
<header>
|
||||
<span class="terminal-agent-icon">
|
||||
<svg class="icon"><use href="#i-bot"></use></svg>
|
||||
</span>
|
||||
<strong id="terminal-workspace">albacore</strong>
|
||||
<span id="terminal-meta">Cross-platform reviewer · Working</span>
|
||||
<button id="close-terminal" aria-label="Close terminal">
|
||||
<svg class="icon"><use href="#i-close"></use></svg>
|
||||
</button>
|
||||
</header>
|
||||
<pre><span class="prompt">›</span> <span id="terminal-command">Reviewing cross-platform behavior…</span>
|
||||
|
||||
<span class="muted">Result summary</span>
|
||||
<span class="success">✓</span> macOS native
|
||||
<span class="success">✓</span> Linux SSH
|
||||
<span class="success">✓</span> Windows WSL
|
||||
|
||||
<span class="muted">All targeted checks pass. Ready for review or follow-up.</span>
|
||||
|
||||
<span class="cursor">▋</span></pre>
|
||||
<label class="terminal-compose">
|
||||
<span>›</span>
|
||||
<input id="terminal-reply" placeholder="Send another message to this agent…" />
|
||||
<kbd>Enter</kbd>
|
||||
</label>
|
||||
<footer>
|
||||
<span id="result-disposition">Unreviewed · remains in Focus</span>
|
||||
<button class="terminal-action" id="pin-agent" aria-pressed="false">
|
||||
<svg class="icon"><use href="#i-pin"></use></svg>
|
||||
Keep visible
|
||||
</button>
|
||||
<button class="terminal-action" id="review-agent">
|
||||
<svg class="icon"><use href="#i-check"></use></svg>
|
||||
Mark reviewed
|
||||
</button>
|
||||
<button class="open-worktree">
|
||||
<svg class="icon"><use href="#i-external"></use></svg>
|
||||
Open worktree
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="./agent-rings-focus.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,445 @@
|
||||
const TOTAL_AGENTS = 196
|
||||
const REVIEWED_DAY_COUNT = 26
|
||||
const REVIEWED_WEEK_COUNT = 104
|
||||
const REVIEWED_ALL_COUNT = 181
|
||||
|
||||
const stateSelection = {
|
||||
attention: true,
|
||||
working: true,
|
||||
finished: true
|
||||
}
|
||||
const projectSelection = {
|
||||
local: true,
|
||||
remote: true
|
||||
}
|
||||
|
||||
let finishedScope = 'review'
|
||||
let hostFilter = 'all'
|
||||
let currentAgentNode = null
|
||||
|
||||
const canvasTitle = document.querySelector('#canvas-title')
|
||||
const visibleSummary = document.querySelector('#visible-summary')
|
||||
const hiddenSummaryCopy = document.querySelector('#hidden-summary-copy')
|
||||
const emptyState = document.querySelector('#empty-state')
|
||||
const doneModeNote = document.querySelector('.done-mode-note')
|
||||
const terminalOverlay = document.querySelector('#terminal-overlay')
|
||||
const terminalWorkspace = document.querySelector('#terminal-workspace')
|
||||
const terminalMeta = document.querySelector('#terminal-meta')
|
||||
const terminalCommand = document.querySelector('#terminal-command')
|
||||
const terminalReply = document.querySelector('#terminal-reply')
|
||||
const resultDisposition = document.querySelector('#result-disposition')
|
||||
const pinAgent = document.querySelector('#pin-agent')
|
||||
const reviewAgent = document.querySelector('#review-agent')
|
||||
const closeTerminal = document.querySelector('#close-terminal')
|
||||
const doneInspector = document.querySelector('#done-inspector')
|
||||
const doneWorkspaceName = document.querySelector('#done-workspace-name')
|
||||
const doneCountLabel = document.querySelector('#done-count-label')
|
||||
const doneAgentGrid = document.querySelector('#done-agent-grid')
|
||||
const closeDoneInspector = document.querySelector('#close-done-inspector')
|
||||
const searchInput = document.querySelector('#agent-search')
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
document.querySelectorAll('kbd[data-mac]').forEach((key) => {
|
||||
key.textContent = isMac ? key.dataset.mac : key.dataset.other
|
||||
})
|
||||
|
||||
function allAgentNodes() {
|
||||
return [...document.querySelectorAll('.agent-node')]
|
||||
}
|
||||
|
||||
function syncAgentState(node) {
|
||||
const state = node.dataset.agentStatus
|
||||
let marker = node.querySelector('.agent-state')
|
||||
if (!marker) {
|
||||
marker = document.createElement('span')
|
||||
node.querySelector('.agent-avatar')?.after(marker)
|
||||
}
|
||||
marker.className = `agent-state ${state}`
|
||||
marker.setAttribute('aria-label', state === 'finished' ? 'Done' : 'Working')
|
||||
marker.innerHTML =
|
||||
state === 'finished'
|
||||
? '<svg class="icon" aria-hidden="true"><use href="#i-circle-check"></use></svg>'
|
||||
: ''
|
||||
}
|
||||
|
||||
function projectAllowed(projectKey) {
|
||||
return projectSelection[projectKey] && (hostFilter === 'all' || hostFilter === projectKey)
|
||||
}
|
||||
|
||||
function isPinned(node) {
|
||||
return node.dataset.pinned === 'true'
|
||||
}
|
||||
|
||||
function finishedNodeMatchesScope(node) {
|
||||
if (finishedScope !== 'review') return true
|
||||
return node.dataset.reviewState === 'unreviewed'
|
||||
}
|
||||
|
||||
function nodeMatchesSearch(node) {
|
||||
const query = searchInput?.value.trim().toLowerCase() ?? ''
|
||||
if (!query) return true
|
||||
return `${node.dataset.agent} ${node.dataset.workspace}`.toLowerCase().includes(query)
|
||||
}
|
||||
|
||||
function shouldShowNode(node) {
|
||||
if (isPinned(node)) return nodeMatchesSearch(node)
|
||||
const projectKey = node.closest('[data-project-key]')?.dataset.projectKey
|
||||
if (!projectAllowed(projectKey) || !nodeMatchesSearch(node)) return false
|
||||
if (node.dataset.agentStatus === 'working') return stateSelection.working
|
||||
return stateSelection.finished && finishedNodeMatchesScope(node)
|
||||
}
|
||||
|
||||
function reviewedClusterCount(cluster) {
|
||||
if (finishedScope === 'day') return Number(cluster.dataset.dayCount)
|
||||
if (finishedScope === 'week') return Number(cluster.dataset.weekCount)
|
||||
if (finishedScope === 'all') return Number(cluster.dataset.allCount)
|
||||
return 0
|
||||
}
|
||||
|
||||
function updateCluster(cluster) {
|
||||
const projectKey = cluster.closest('[data-project-key]')?.dataset.projectKey
|
||||
const count = reviewedClusterCount(cluster)
|
||||
const queryActive = Boolean(searchInput?.value.trim())
|
||||
const visible = stateSelection.finished && projectAllowed(projectKey) && count > 0 && !queryActive
|
||||
cluster.classList.toggle('is-visible', visible)
|
||||
cluster.dataset.currentCount = String(count)
|
||||
const label = cluster.querySelector('strong')
|
||||
if (label) label.textContent = `${count} reviewed`
|
||||
return visible ? count : 0
|
||||
}
|
||||
|
||||
function scopeFinishedCount(individualFinishedCount) {
|
||||
if (finishedScope === 'day') return individualFinishedCount + REVIEWED_DAY_COUNT
|
||||
if (finishedScope === 'week') return individualFinishedCount + REVIEWED_WEEK_COUNT
|
||||
if (finishedScope === 'all') return individualFinishedCount + REVIEWED_ALL_COUNT
|
||||
return allAgentNodes().filter(
|
||||
(node) => node.dataset.agentStatus === 'finished' && node.dataset.reviewState === 'unreviewed'
|
||||
).length
|
||||
}
|
||||
|
||||
function updateCounts(visibleCount) {
|
||||
const nodes = allAgentNodes()
|
||||
const workingCount = nodes.filter((node) => node.dataset.agentStatus === 'working').length
|
||||
const individualFinishedCount = nodes.filter(
|
||||
(node) => node.dataset.agentStatus === 'finished'
|
||||
).length
|
||||
const unreviewedCount = nodes.filter(
|
||||
(node) => node.dataset.agentStatus === 'finished' && node.dataset.reviewState === 'unreviewed'
|
||||
).length
|
||||
const totalFinished = individualFinishedCount + REVIEWED_ALL_COUNT
|
||||
document.querySelector('#working-count').textContent = String(workingCount)
|
||||
document.querySelector('#finished-count').textContent = String(totalFinished)
|
||||
document.querySelector('#review-count').textContent = String(unreviewedCount)
|
||||
document.querySelector('#day-count').textContent = String(
|
||||
individualFinishedCount + REVIEWED_DAY_COUNT
|
||||
)
|
||||
document.querySelector('#week-count').textContent = String(
|
||||
individualFinishedCount + REVIEWED_WEEK_COUNT
|
||||
)
|
||||
document.querySelector('#all-finished-count').textContent = String(totalFinished)
|
||||
canvasTitle.textContent = `Focus · ${visibleCount} of ${TOTAL_AGENTS} agents`
|
||||
visibleSummary.textContent = `${visibleCount} of ${TOTAL_AGENTS} shown`
|
||||
const hiddenCount = TOTAL_AGENTS - visibleCount
|
||||
const defaultFocus =
|
||||
stateSelection.attention &&
|
||||
stateSelection.working &&
|
||||
stateSelection.finished &&
|
||||
finishedScope === 'review' &&
|
||||
projectSelection.local &&
|
||||
projectSelection.remote &&
|
||||
hostFilter === 'all' &&
|
||||
!searchInput?.value.trim()
|
||||
hiddenSummaryCopy.textContent = defaultFocus
|
||||
? `${hiddenCount} reviewed results hidden`
|
||||
: `${hiddenCount} agents hidden by filters`
|
||||
document.querySelector('#show-reviewed').textContent = hiddenSummaryCopy.textContent
|
||||
const finishedScopeCount = scopeFinishedCount(individualFinishedCount)
|
||||
doneModeNote?.classList.toggle(
|
||||
'is-visible',
|
||||
stateSelection.finished && finishedScopeCount > unreviewedCount
|
||||
)
|
||||
}
|
||||
|
||||
function updateContainers() {
|
||||
document.querySelectorAll('.workspace-ring').forEach((workspace) => {
|
||||
const hasVisibleContent = Boolean(
|
||||
workspace.querySelector('.agent-node:not(.is-filtered-out), .done-cluster.is-visible')
|
||||
)
|
||||
workspace.classList.toggle('is-filtered-out', !hasVisibleContent)
|
||||
})
|
||||
document.querySelectorAll('[data-project-key]').forEach((project) => {
|
||||
const hasPinned = Boolean(project.querySelector('.agent-node.is-pinned'))
|
||||
const hasVisibleContent = Boolean(
|
||||
project.querySelector(
|
||||
'.agent-node:not(.is-filtered-out), .done-cluster.is-visible, .workspace-ring:not(.is-filtered-out)'
|
||||
)
|
||||
)
|
||||
project.classList.toggle('is-filtered-out', !hasVisibleContent && !hasPinned)
|
||||
})
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
let visibleCount = 0
|
||||
allAgentNodes().forEach((node) => {
|
||||
const visible = shouldShowNode(node)
|
||||
node.classList.toggle('is-filtered-out', !visible)
|
||||
if (visible) visibleCount += 1
|
||||
})
|
||||
document.querySelectorAll('.done-cluster').forEach((cluster) => {
|
||||
visibleCount += updateCluster(cluster)
|
||||
})
|
||||
updateContainers()
|
||||
emptyState?.classList.toggle('is-visible', visibleCount === 0)
|
||||
updateCounts(visibleCount)
|
||||
document.querySelectorAll('[data-finished-scope]').forEach((button) => {
|
||||
button.disabled = !stateSelection.finished
|
||||
})
|
||||
}
|
||||
|
||||
function setFinishedScope(scope) {
|
||||
finishedScope = scope
|
||||
document.querySelectorAll('[data-finished-scope]').forEach((button) => {
|
||||
button.classList.toggle('is-active', button.dataset.finishedScope === scope)
|
||||
})
|
||||
closeCompletedAgents()
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-state-toggle]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const state = button.dataset.stateToggle
|
||||
stateSelection[state] = !stateSelection[state]
|
||||
button.classList.toggle('is-active', stateSelection[state])
|
||||
button.setAttribute('aria-pressed', String(stateSelection[state]))
|
||||
applyFilters()
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-finished-scope]').forEach((button) => {
|
||||
button.addEventListener('click', () => setFinishedScope(button.dataset.finishedScope))
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-project-toggle]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const project = button.dataset.projectToggle
|
||||
projectSelection[project] = !projectSelection[project]
|
||||
button.classList.toggle('is-active', projectSelection[project])
|
||||
button.setAttribute('aria-pressed', String(projectSelection[project]))
|
||||
applyFilters()
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-host-filter]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
hostFilter = button.dataset.hostFilter
|
||||
document.querySelectorAll('[data-host-filter]').forEach((option) => {
|
||||
option.classList.toggle('is-active', option === button)
|
||||
})
|
||||
applyFilters()
|
||||
})
|
||||
})
|
||||
|
||||
function resetFocus() {
|
||||
Object.assign(stateSelection, { attention: true, working: true, finished: true })
|
||||
Object.assign(projectSelection, { local: true, remote: true })
|
||||
hostFilter = 'all'
|
||||
searchInput.value = ''
|
||||
document.querySelectorAll('[data-state-toggle], [data-project-toggle]').forEach((button) => {
|
||||
button.classList.add('is-active')
|
||||
button.setAttribute('aria-pressed', 'true')
|
||||
})
|
||||
document.querySelectorAll('[data-host-filter]').forEach((button) => {
|
||||
button.classList.toggle('is-active', button.dataset.hostFilter === 'all')
|
||||
})
|
||||
setFinishedScope('review')
|
||||
}
|
||||
|
||||
function showAllAgents() {
|
||||
resetFocus()
|
||||
setFinishedScope('all')
|
||||
}
|
||||
|
||||
document.querySelector('#clear-filters')?.addEventListener('click', showAllAgents)
|
||||
document.querySelector('#show-reviewed')?.addEventListener('click', showAllAgents)
|
||||
document.querySelector('#hidden-summary')?.addEventListener('click', showAllAgents)
|
||||
document.querySelector('#reset-empty-state')?.addEventListener('click', resetFocus)
|
||||
searchInput?.addEventListener('input', applyFilters)
|
||||
|
||||
function updateTerminalActions(node, detachedResult = false) {
|
||||
const finished = node?.dataset.agentStatus === 'finished'
|
||||
pinAgent.hidden = !finished
|
||||
reviewAgent.hidden = !finished
|
||||
if (!finished) {
|
||||
resultDisposition.textContent = detachedResult
|
||||
? 'Reviewed result · reopened from history'
|
||||
: 'Working · conversation remains visible'
|
||||
return
|
||||
}
|
||||
const pinned = isPinned(node)
|
||||
pinAgent.setAttribute('aria-pressed', String(pinned))
|
||||
pinAgent.lastChild.textContent = pinned ? ' Pinned' : ' Keep visible'
|
||||
const reviewed = node.dataset.reviewState === 'reviewed'
|
||||
resultDisposition.textContent = reviewed
|
||||
? pinned
|
||||
? 'Reviewed · pinned in Focus'
|
||||
: 'Reviewed result'
|
||||
: 'Seen · remains in Focus until reviewed'
|
||||
reviewAgent.hidden = reviewed
|
||||
}
|
||||
|
||||
function openAgentTerminal({ agent, workspace, duration, done = false, node = null }) {
|
||||
if (!terminalOverlay) return
|
||||
currentAgentNode = node
|
||||
if (node?.dataset.agentStatus === 'finished') {
|
||||
node.dataset.seen = 'true'
|
||||
}
|
||||
terminalWorkspace.textContent = workspace
|
||||
terminalMeta.textContent = `${agent} · ${done ? `Finished ${duration}` : `Working ${duration}`}`
|
||||
terminalCommand.textContent = done
|
||||
? `${agent} finished. The result is ready for review.`
|
||||
: `Live output from ${agent}…`
|
||||
terminalReply.value = ''
|
||||
terminalReply.placeholder = done
|
||||
? 'Send another message to continue this agent…'
|
||||
: 'Send another message to this agent…'
|
||||
updateTerminalActions(node, done && !node)
|
||||
terminalOverlay.classList.add('is-open')
|
||||
terminalOverlay.setAttribute('aria-hidden', 'false')
|
||||
terminalReply.focus()
|
||||
}
|
||||
|
||||
function dismissTerminal() {
|
||||
terminalOverlay?.classList.remove('is-open')
|
||||
terminalOverlay?.setAttribute('aria-hidden', 'true')
|
||||
currentAgentNode = null
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
document.querySelectorAll('.agent-node').forEach((node) => {
|
||||
syncAgentState(node)
|
||||
node.addEventListener('click', () => {
|
||||
openAgentTerminal({
|
||||
agent: node.dataset.agent,
|
||||
workspace: node.dataset.workspace,
|
||||
duration: node.dataset.duration,
|
||||
done: node.dataset.agentStatus === 'finished',
|
||||
node
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
closeTerminal?.addEventListener('click', dismissTerminal)
|
||||
terminalOverlay?.addEventListener('click', (event) => {
|
||||
if (event.target === terminalOverlay) dismissTerminal()
|
||||
})
|
||||
|
||||
pinAgent?.addEventListener('click', () => {
|
||||
if (!currentAgentNode) return
|
||||
currentAgentNode.dataset.pinned = String(!isPinned(currentAgentNode))
|
||||
currentAgentNode.classList.toggle('is-pinned', isPinned(currentAgentNode))
|
||||
updateTerminalActions(currentAgentNode)
|
||||
applyFilters()
|
||||
})
|
||||
|
||||
reviewAgent?.addEventListener('click', () => {
|
||||
if (!currentAgentNode) return
|
||||
currentAgentNode.dataset.reviewState = 'reviewed'
|
||||
currentAgentNode.dataset.seen = 'true'
|
||||
if (isPinned(currentAgentNode)) {
|
||||
updateTerminalActions(currentAgentNode)
|
||||
applyFilters()
|
||||
} else {
|
||||
dismissTerminal()
|
||||
}
|
||||
})
|
||||
|
||||
terminalReply?.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' || !terminalReply.value.trim()) return
|
||||
event.preventDefault()
|
||||
terminalCommand.textContent = `Continuing with: ${terminalReply.value.trim()}`
|
||||
terminalMeta.textContent = terminalMeta.textContent.replace(/Finished .+$/, 'Working · just now')
|
||||
terminalReply.value = ''
|
||||
terminalReply.placeholder = 'Message sent — the agent is working…'
|
||||
if (currentAgentNode?.dataset.agentStatus === 'finished') {
|
||||
currentAgentNode.dataset.agentStatus = 'working'
|
||||
currentAgentNode.dataset.duration = 'just now'
|
||||
currentAgentNode.classList.remove('agent-finished')
|
||||
currentAgentNode.querySelector('small').textContent = 'now'
|
||||
syncAgentState(currentAgentNode)
|
||||
}
|
||||
updateTerminalActions(currentAgentNode)
|
||||
applyFilters()
|
||||
})
|
||||
|
||||
function completedAgentButton(workspace, index) {
|
||||
const button = document.createElement('button')
|
||||
button.className = 'done-agent'
|
||||
button.innerHTML = `
|
||||
<span><svg class="icon"><use href="#i-circle-check"></use></svg></span>
|
||||
<small>Agent ${String(index + 1).padStart(2, '0')}</small>
|
||||
`
|
||||
button.addEventListener('click', () => {
|
||||
openAgentTerminal({
|
||||
agent: `Agent ${String(index + 1).padStart(2, '0')}`,
|
||||
workspace,
|
||||
duration: 'earlier',
|
||||
done: true
|
||||
})
|
||||
})
|
||||
return button
|
||||
}
|
||||
|
||||
function openCompletedAgents(cluster) {
|
||||
if (!doneInspector || !doneAgentGrid) return
|
||||
const workspace =
|
||||
cluster.closest('.workspace-ring')?.querySelector('header strong')?.textContent ?? 'Workspace'
|
||||
const count = Number(cluster.dataset.currentCount)
|
||||
doneWorkspaceName.textContent = workspace
|
||||
doneCountLabel.textContent = `${count} reviewed agents`
|
||||
doneAgentGrid.replaceChildren()
|
||||
for (let index = 0; index < Math.min(count, 14); index += 1) {
|
||||
doneAgentGrid.append(completedAgentButton(workspace, index))
|
||||
}
|
||||
doneInspector.classList.add('is-open')
|
||||
}
|
||||
|
||||
function closeCompletedAgents() {
|
||||
doneInspector?.classList.remove('is-open')
|
||||
}
|
||||
|
||||
document.querySelectorAll('.done-cluster').forEach((cluster) => {
|
||||
cluster.addEventListener('click', () => openCompletedAgents(cluster))
|
||||
})
|
||||
|
||||
closeDoneInspector?.addEventListener('click', closeCompletedAgents)
|
||||
|
||||
document.querySelector('#review-visible')?.addEventListener('click', () => {
|
||||
allAgentNodes().forEach((node) => {
|
||||
if (
|
||||
!node.classList.contains('is-filtered-out') &&
|
||||
node.dataset.agentStatus === 'finished' &&
|
||||
node.dataset.reviewState === 'unreviewed'
|
||||
) {
|
||||
node.dataset.reviewState = 'reviewed'
|
||||
node.dataset.seen = 'true'
|
||||
}
|
||||
})
|
||||
applyFilters()
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (terminalOverlay?.classList.contains('is-open')) {
|
||||
dismissTerminal()
|
||||
} else {
|
||||
closeCompletedAgents()
|
||||
}
|
||||
}
|
||||
const usesPlatformModifier = isMac ? event.metaKey : event.ctrlKey
|
||||
if (usesPlatformModifier && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
searchInput?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
applyFilters()
|
||||
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,224 @@
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url("../src/renderer/src/assets/fonts/Geist-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #fff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #fff;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--border: #e5e5e5;
|
||||
--ring: #a1a1a1;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fafafa;
|
||||
--status-success: #15803d;
|
||||
--attention: #f59e0b;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #171717;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #404040;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--ring: #737373;
|
||||
--primary: #e5e5e5;
|
||||
--primary-foreground: #171717;
|
||||
--status-success: #86efac;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; overflow: hidden; background: var(--background); color: var(--foreground); font: 14px/1.45 Geist, sans-serif; letter-spacing: .01em; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { color: inherit; cursor: pointer; }
|
||||
.svg-defs { position: absolute; width: 0; height: 0; }
|
||||
.icon { width: 15px; height: 15px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.eyebrow { color: var(--muted-foreground); font-size: 9px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; }
|
||||
kbd { padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; background: var(--muted); color: var(--muted-foreground); font-size: 8px; white-space: nowrap; }
|
||||
.command-shell { min-width: 1280px; height: 100vh; min-height: 780px; overflow: hidden; }
|
||||
|
||||
.topbar { height: 62px; display: flex; align-items: center; gap: 13px; padding: 0 17px; border-bottom: 1px solid var(--border); }
|
||||
.orca-mark { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 8px; background: var(--primary); color: var(--primary-foreground); font-weight: 800; }
|
||||
.title { min-width: 90px; display: flex; flex-direction: column; line-height: 1.15; }
|
||||
.title strong { font-size: 16px; }
|
||||
.title span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.fleet-total { height: 32px; display: grid; grid-template-columns: auto auto 6px; align-items: center; gap: 5px; padding: 0 9px; border-radius: 7px; background: var(--muted); }
|
||||
.fleet-total strong { font-size: 14px; }
|
||||
.fleet-total span { color: var(--muted-foreground); font-size: 8px; text-transform: uppercase; }
|
||||
.fleet-total i { width: 5px; height: 5px; border-radius: 50%; background: var(--status-success); box-shadow: 0 0 6px color-mix(in srgb, var(--status-success) 60%, transparent); }
|
||||
.topbar nav { height: 33px; display: flex; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.topbar nav button { padding: 0 9px; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); font-size: 9px; }
|
||||
.topbar nav button:hover, .topbar nav button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.search { width: min(280px, 19vw); height: 32px; display: flex; align-items: center; gap: 7px; margin-left: auto; padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--muted) 55%, transparent); color: var(--muted-foreground); }
|
||||
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: 10px; }
|
||||
.orchestrator { height: 32px; display: flex; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); font-size: 9px; }
|
||||
.orchestrator span { width: 6px; height: 6px; border: 1px solid var(--muted-foreground); border-radius: 50%; box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted-foreground) 10%, transparent); }
|
||||
|
||||
.command-grid { height: calc(100vh - 62px); display: grid; grid-template-columns: 235px minmax(710px, 1fr) 345px; }
|
||||
.attention-rail, .response-panel { min-height: 0; background: color-mix(in srgb, var(--card) 67%, var(--background)); }
|
||||
.attention-rail { display: flex; flex-direction: column; padding: 13px 9px 10px; border-right: 1px solid var(--border); }
|
||||
.attention-rail > header { padding: 2px 7px 12px; border-bottom: 1px solid var(--border); }
|
||||
.attention-rail > header div { display: flex; align-items: center; justify-content: space-between; }
|
||||
.attention-rail > header strong { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 50%; background: color-mix(in srgb, var(--attention) 13%, transparent); color: var(--attention); font-size: 9px; }
|
||||
.attention-rail > header p { margin: 5px 0 0; color: var(--muted-foreground); font-size: 9px; }
|
||||
.attention-row { width: 100%; display: grid; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 9px 7px; border: 1px solid transparent; border-radius: 7px; background: transparent; text-align: left; }
|
||||
.attention-row:hover { background: var(--accent); }
|
||||
.attention-row.is-selected { margin-top: 8px; border-color: color-mix(in srgb, var(--attention) 24%, transparent); background: color-mix(in srgb, var(--attention) 7%, transparent); }
|
||||
.attention-row > i { width: 6px; height: 6px; border-radius: 50%; background: var(--attention); box-shadow: 0 0 7px color-mix(in srgb, var(--attention) 60%, transparent); }
|
||||
.attention-row > span { min-width: 0; display: flex; flex-direction: column; }
|
||||
.attention-row strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.attention-row small, .attention-row time { color: var(--muted-foreground); font-size: 7px; }
|
||||
.attention-row.more { grid-template-columns: 1fr; margin-top: 3px; padding-left: 20px; }
|
||||
.rail-watch { margin: 10px 5px 0; padding-top: 11px; border-top: 1px solid var(--border); }
|
||||
.rail-watch > p { display: grid; grid-template-columns: 6px 1fr; align-items: center; gap: 7px; margin: 11px 2px; }
|
||||
.rail-watch > p > i { width: 6px; height: 6px; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.rail-watch p span { display: flex; flex-direction: column; }
|
||||
.rail-watch strong { font-size: 9px; }
|
||||
.rail-watch small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.attention-rail > footer { display: grid; gap: 5px; margin-top: auto; }
|
||||
.attention-rail > footer button { height: 30px; display: flex; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); color: var(--muted-foreground); font-size: 8px; }
|
||||
.attention-rail > footer b { margin-left: auto; font-size: 8px; }
|
||||
|
||||
.fleet-stage { min-width: 0; min-height: 0; display: grid; grid-template-rows: 56px minmax(0, 1fr) 54px; overflow: hidden; }
|
||||
.stage-header { display: flex; align-items: center; padding: 0 13px; border-bottom: 1px solid var(--border); }
|
||||
.stage-header > div:first-child { display: flex; flex-direction: column; }
|
||||
.stage-header strong { font-size: 11px; }
|
||||
.legend { display: flex; gap: 12px; margin-left: auto; }
|
||||
.legend span { display: flex; align-items: center; gap: 5px; color: var(--muted-foreground); font-size: 8px; }
|
||||
.legend i { width: 5px; height: 5px; border-radius: 50%; background: var(--muted-foreground); opacity: .45; }
|
||||
.legend i.needs { width: 6px; height: 6px; background: var(--attention); opacity: 1; box-shadow: 0 0 6px var(--attention); }
|
||||
.legend i.active { border: 1px solid var(--muted-foreground); background: transparent; opacity: 1; }
|
||||
.legend i.done { background: var(--status-success); opacity: .65; }
|
||||
.legend b { color: var(--foreground); font-size: 9px; }
|
||||
.radar-field { position: relative; overflow: hidden; background-image: radial-gradient(color-mix(in srgb, var(--muted-foreground) 14%, transparent) .55px, transparent .55px); background-size: 20px 20px; }
|
||||
.radar-field::before { position: absolute; inset: 0; background: radial-gradient(circle, color-mix(in srgb, var(--muted) 25%, transparent), transparent 62%); content: ""; }
|
||||
.radar-ring { position: absolute; top: 50%; left: 50%; border: 1px solid color-mix(in srgb, var(--muted-foreground) 24%, transparent); border-radius: 50%; transform: translate(-50%, -50%); }
|
||||
.radar-ring span { position: absolute; top: -7px; left: 50%; padding: 0 5px; background: var(--background); color: var(--muted-foreground); font-size: 6px; letter-spacing: .08em; transform: translateX(-50%); }
|
||||
.ring-one { width: 185px; height: 185px; border-color: color-mix(in srgb, var(--attention) 35%, transparent); }
|
||||
.ring-two { width: 330px; height: 330px; }
|
||||
.ring-three { width: 475px; height: 475px; }
|
||||
.ring-four { width: 620px; height: 620px; }
|
||||
.crosshair { position: absolute; top: 50%; left: 50%; background: color-mix(in srgb, var(--muted-foreground) 14%, transparent); transform: translate(-50%, -50%); }
|
||||
.crosshair.horizontal { width: 94%; height: 1px; }
|
||||
.crosshair.vertical { width: 1px; height: 94%; }
|
||||
.project-axis { position: absolute; z-index: 2; top: 50%; left: 50%; width: 45%; height: 1px; background: linear-gradient(90deg, color-mix(in srgb, var(--ring) 45%, transparent), transparent); transform-origin: 0 0; }
|
||||
.project-axis span { position: absolute; right: 0; top: -15px; color: var(--muted-foreground); font-size: 6px; letter-spacing: .06em; }
|
||||
.axis-a { transform: rotate(-36deg); }
|
||||
.axis-b { transform: rotate(110deg); }
|
||||
.axis-c { transform: rotate(198deg); }
|
||||
.radar-sweep { position: absolute; z-index: 1; top: 50%; left: 50%; width: 310px; height: 310px; border-radius: 0 100% 0 0; background: conic-gradient(from 0deg, color-mix(in srgb, var(--ring) 14%, transparent), transparent 34deg); transform-origin: 0 0; animation: sweep 9s linear infinite; }
|
||||
.fleet-core { position: absolute; z-index: 6; top: 50%; left: 50%; width: 110px; height: 110px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--ring); border-radius: 50%; background: var(--card); transform: translate(-50%, -50%); }
|
||||
.fleet-core strong { font-size: 23px; line-height: 1; }
|
||||
.fleet-core span { margin-top: 3px; color: var(--muted-foreground); font-size: 7px; text-transform: uppercase; }
|
||||
.fleet-core small { margin-top: 5px; color: var(--muted-foreground); font-size: 6px; }
|
||||
#fleet-blips i { position: absolute; z-index: 4; width: 4px; height: 4px; border-radius: 50%; background: var(--muted-foreground); opacity: .38; transform: translate(-50%, -50%); }
|
||||
#fleet-blips i.active { width: 6px; height: 6px; border: 1px solid var(--muted-foreground); background: var(--background); opacity: .9; animation: agent-pulse 1.8s ease-in-out infinite; }
|
||||
#fleet-blips i.needs { width: 7px; height: 7px; background: var(--attention); opacity: 1; box-shadow: 0 0 8px color-mix(in srgb, var(--attention) 85%, transparent); }
|
||||
#fleet-blips i.done { background: var(--status-success); opacity: .42; }
|
||||
.cluster-label { position: absolute; z-index: 7; display: grid; grid-template-columns: 6px auto; gap: 0 6px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: color-mix(in srgb, var(--card) 92%, transparent); text-align: left; }
|
||||
.cluster-label:hover, .cluster-label.is-selected { border-color: color-mix(in srgb, var(--attention) 35%, transparent); background: color-mix(in srgb, var(--attention) 7%, var(--card)); }
|
||||
.cluster-label i { width: 6px; height: 6px; grid-row: span 2; margin-top: 3px; border-radius: 50%; background: var(--muted-foreground); }
|
||||
.cluster-label.is-selected i, .label-mobile i, .label-trust i { background: var(--attention); box-shadow: 0 0 6px var(--attention); }
|
||||
.cluster-label strong { font-size: 8px; }
|
||||
.cluster-label span { color: var(--muted-foreground); font-size: 7px; }
|
||||
.label-review { top: 38%; left: 41%; }
|
||||
.label-pr { top: 19%; right: 17%; }
|
||||
.label-mobile { top: 54%; left: 20%; }
|
||||
.label-resume { right: 14%; bottom: 14%; }
|
||||
.label-trust { left: 26%; bottom: 16%; }
|
||||
.zoom-controls { position: absolute; z-index: 8; bottom: 11px; left: 11px; display: flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); }
|
||||
.zoom-controls button, .zoom-controls span { height: 24px; display: grid; place-items: center; padding: 0 7px; border: 0; border-radius: 4px; background: transparent; color: var(--muted-foreground); font-size: 7px; }
|
||||
.zoom-controls button:hover { background: var(--muted); }
|
||||
.minimap { position: absolute; z-index: 8; right: 11px; bottom: 11px; width: 94px; height: 57px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); opacity: .88; }
|
||||
.minimap::before { position: absolute; inset: 8px 12px; border: 1px solid var(--ring); content: ""; }
|
||||
.minimap span, .minimap i, .minimap b, .minimap em { position: absolute; width: 4px; height: 4px; border-radius: 50%; background: var(--muted-foreground); }
|
||||
.minimap span { top: 14px; left: 21px; }.minimap i { top: 25px; right: 25px; }.minimap b { bottom: 13px; left: 41px; }.minimap em { right: 16px; bottom: 10px; background: var(--attention); }
|
||||
.signal-ticker { display: grid; grid-template-columns: 48px repeat(3, 1fr); align-items: center; border-top: 1px solid var(--border); background: color-mix(in srgb, var(--card) 70%, var(--background)); }
|
||||
.ticker-live { display: flex; align-items: center; gap: 5px; padding-left: 10px; color: var(--status-success); font-size: 7px; }
|
||||
.ticker-live i { width: 5px; height: 5px; border-radius: 50%; background: var(--status-success); }
|
||||
.signal-ticker p { min-width: 0; display: grid; grid-template-columns: 1fr auto; margin: 0; padding: 0 10px; border-left: 1px solid var(--border); }
|
||||
.signal-ticker strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.signal-ticker span { overflow: hidden; grid-row: 2; color: var(--muted-foreground); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.signal-ticker time { grid-row: span 2; color: var(--muted-foreground); font-size: 7px; }
|
||||
|
||||
.response-panel { display: flex; min-height: 0; flex-direction: column; padding: 15px; border-left: 1px solid var(--border); overflow: auto; }
|
||||
.response-panel > header > div { display: flex; align-items: center; gap: 6px; }
|
||||
.signal-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--attention); box-shadow: 0 0 7px var(--attention); }
|
||||
.response-panel h1 { margin: 6px 0 2px; font-size: 16px; }
|
||||
.response-panel > header > p { margin: 0; color: var(--muted-foreground); font-size: 9px; }
|
||||
.panel-badges { display: flex; gap: 4px; margin-top: 10px; }
|
||||
.panel-badges span { padding: 3px 6px; border-radius: 4px; background: var(--muted); color: var(--muted-foreground); font-size: 7px; }
|
||||
.response-panel > section { margin-top: 13px; padding-top: 13px; border-top: 1px solid var(--border); }
|
||||
.decision-card strong { display: block; margin: 6px 0 5px; font-size: 11px; }
|
||||
.decision-card p { margin: 0; color: var(--muted-foreground); font-size: 8px; }
|
||||
.evidence > p { display: grid; grid-template-columns: 20px 1fr; align-items: center; gap: 7px; margin: 9px 0; }
|
||||
.evidence .icon { width: 18px; height: 18px; padding: 4px; border-radius: 5px; background: var(--muted); color: var(--muted-foreground); }
|
||||
.evidence .icon.success { color: var(--status-success); }
|
||||
.evidence p span { display: flex; flex-direction: column; }
|
||||
.evidence strong { font-size: 9px; }
|
||||
.evidence small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.evidence > button { width: 100%; display: flex; justify-content: space-between; margin-top: 9px; padding: 7px 0 0; border: 0; border-top: 1px solid var(--border); background: transparent; font-size: 8px; }
|
||||
.recipients > div { display: grid; grid-template-columns: 25px 1fr auto; align-items: center; gap: 7px; margin-top: 6px; padding: 6px; border-radius: 6px; background: var(--muted); }
|
||||
.recipients i { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 5px; background: var(--card); font-size: 8px; font-style: normal; font-weight: 700; }
|
||||
.recipients div span { display: flex; flex-direction: column; }
|
||||
.recipients strong { font-size: 8px; }
|
||||
.recipients small, .recipients b { color: var(--muted-foreground); font-size: 7px; font-weight: 500; }
|
||||
.response-form { flex: 0 0 auto; margin-top: auto; overflow: hidden; border: 1px solid var(--ring); border-radius: 9px; background: var(--card); box-shadow: 0 10px 24px rgb(0 0 0 / .18); }
|
||||
.response-form > div { height: 32px; display: flex; align-items: center; padding: 0 8px; border-bottom: 1px solid var(--border); color: var(--muted-foreground); font-size: 8px; }
|
||||
.response-form > div strong { color: var(--foreground); }
|
||||
.response-form > div button { height: 24px; display: flex; align-items: center; gap: 4px; margin-left: auto; padding: 0 6px; border: 1px solid var(--border); border-radius: 5px; background: transparent; font-size: 7px; }
|
||||
.response-form textarea { width: 100%; min-height: 90px; display: block; resize: none; padding: 9px; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: 9px; line-height: 1.55; }
|
||||
.response-form footer { min-height: 37px; display: flex; align-items: center; gap: 5px; padding: 5px 6px 5px 9px; border-top: 1px solid var(--border); }
|
||||
.response-form footer > span { margin-right: auto; color: var(--muted-foreground); font-size: 7px; }
|
||||
.response-form footer button { height: 27px; display: flex; align-items: center; gap: 5px; padding: 0 7px; border: 1px solid var(--primary); border-radius: 6px; background: var(--primary); color: var(--primary-foreground); font-size: 8px; }
|
||||
.response-form footer button kbd { border-color: color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); color: inherit; }
|
||||
|
||||
.terminal-backdrop { position: fixed; z-index: 20; inset: 62px 0 0; display: none; align-items: center; justify-content: center; background: rgb(0 0 0 / .5); backdrop-filter: blur(2px); }
|
||||
.terminal-backdrop.is-open { display: flex; animation: fade-in .14s ease-out; }
|
||||
.terminal-popover { width: min(940px, 72vw); height: min(590px, 70vh); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--ring); border-radius: 12px; background: var(--background); box-shadow: 0 10px 24px rgb(0 0 0 / .18); }
|
||||
.terminal-popover > header { height: 46px; display: flex; align-items: center; gap: 4px; padding: 0 9px 0 14px; border-bottom: 1px solid var(--border); }
|
||||
.terminal-popover > header > div { display: flex; align-items: center; gap: 7px; margin-right: auto; }
|
||||
.terminal-popover > header i { width: 7px; height: 7px; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.terminal-popover > header span { display: flex; flex-direction: column; }
|
||||
.terminal-popover > header strong { font-size: 9px; }
|
||||
.terminal-popover > header small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.terminal-popover > header button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); }
|
||||
.terminal-popover > header button:hover { background: var(--muted); }
|
||||
.terminal-popover > nav { height: 33px; display: flex; align-items: center; gap: 2px; padding: 0 10px; border-bottom: 1px solid var(--border); }
|
||||
.terminal-popover > nav button { height: 23px; padding: 0 8px; border: 0; border-radius: 4px; background: transparent; color: var(--muted-foreground); font-size: 7px; }
|
||||
.terminal-popover > nav button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.terminal-popover > nav span { margin-left: auto; color: var(--status-success); font-size: 7px; }
|
||||
.terminal-popover pre { flex: 1; margin: 0; padding: 25px; color: var(--primary); font: 12px/1.8 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
|
||||
.terminal-popover pre .muted { color: var(--ring); }
|
||||
.terminal-popover pre .success { color: var(--status-success); }
|
||||
.terminal-popover pre b { padding: 2px 4px; border-radius: 3px; background: var(--muted); color: var(--foreground); font-weight: 500; }
|
||||
.terminal-popover pre .waiting { color: var(--attention); }
|
||||
.terminal-popover > footer { height: 45px; display: flex; align-items: center; padding: 0 10px 0 14px; border-top: 1px solid var(--border); }
|
||||
.terminal-popover > footer > span { margin-right: auto; color: var(--ring); font-size: 7px; }
|
||||
.terminal-popover > footer button { height: 28px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); font-size: 8px; }
|
||||
.sent-toast { position: fixed; z-index: 30; right: 18px; bottom: 18px; display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--status-success) 25%, transparent); border-radius: 8px; background: var(--card); box-shadow: 0 10px 24px rgb(0 0 0 / .18); opacity: 0; pointer-events: none; transform: translateY(8px); transition: .16s ease-out; }
|
||||
.sent-toast.is-visible { opacity: 1; transform: translateY(0); }
|
||||
.sent-toast .icon { color: var(--status-success); }
|
||||
.sent-toast span { display: flex; flex-direction: column; color: var(--muted-foreground); font-size: 7px; }
|
||||
.sent-toast strong { color: var(--foreground); font-size: 9px; }
|
||||
|
||||
@keyframes sweep { to { transform: rotate(360deg); } }
|
||||
@keyframes agent-pulse { 50% { opacity: .32; } }
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
@media (max-width: 1420px) {
|
||||
.command-grid { grid-template-columns: 220px minmax(670px, 1fr) 325px; }
|
||||
.topbar nav button { padding: 0 7px; }
|
||||
.search { width: 205px; }
|
||||
.ring-four { width: 570px; height: 570px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Fleet Command Center</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./fleet-command-center.css" />
|
||||
</head>
|
||||
<body class="dark">
|
||||
<svg class="svg-defs" aria-hidden="true">
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><path d="m4 6 5 5-5 5M12 18h8"></path></symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M4 5h16M7 12h10M10 19h4"></path></symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4zM22 2 11 13"></path></symbol>
|
||||
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"></path></symbol>
|
||||
<symbol id="i-expand" viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"></path></symbol>
|
||||
<symbol id="i-zap" viewBox="0 0 24 24"><path d="m13 2-9 12h8l-1 8 9-12h-8z"></path></symbol>
|
||||
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"></path><circle cx="12" cy="12" r="3"></circle></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"></path></symbol>
|
||||
<symbol id="i-layout" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M3 9h18M9 9v12"></path></symbol>
|
||||
</svg>
|
||||
|
||||
<main class="command-shell">
|
||||
<header class="topbar">
|
||||
<div class="orca-mark">O</div>
|
||||
<div class="title"><strong>Agents</strong><span>Fleet command</span></div>
|
||||
<div class="fleet-total"><strong>168</strong><span>accounted for</span><i></i></div>
|
||||
<nav>
|
||||
<button class="is-active">Command radar</button>
|
||||
<button>Matrix</button>
|
||||
<button>Timeline</button>
|
||||
<button>History</button>
|
||||
</nav>
|
||||
<label class="search"><svg class="icon"><use href="#i-search"></use></svg><input placeholder="Find any agent…" /><kbd data-mac="⌘ K" data-other="Ctrl+K">⌘ K</kbd></label>
|
||||
<button class="orchestrator"><span></span>Ask Orchestrator<kbd data-mac="⌘ ⇧ O" data-other="Ctrl+Shift+O">⌘ ⇧ O</kbd></button>
|
||||
</header>
|
||||
|
||||
<section class="command-grid">
|
||||
<aside class="attention-rail">
|
||||
<header><div><span class="eyebrow">Needs you</span><strong>9</strong></div><p>Four decisions can unblock the queue.</p></header>
|
||||
<button class="attention-row is-selected">
|
||||
<i></i><span><strong>Runtime cleanup behavior</strong><small>3 agents · 2 worktrees</small></span><time>now</time>
|
||||
</button>
|
||||
<button class="attention-row">
|
||||
<i></i><span><strong>Duplicate mobile sessions</strong><small>2 agents · mobile-9717</small></span><time>3m</time>
|
||||
</button>
|
||||
<button class="attention-row">
|
||||
<i></i><span><strong>Trust dialog permission</strong><small>1 agent · desktop test</small></span><time>7m</time>
|
||||
</button>
|
||||
<button class="attention-row">
|
||||
<i></i><span><strong>Review scope changed</strong><small>1 agent · pr-9190</small></span><time>8m</time>
|
||||
</button>
|
||||
<button class="attention-row more">
|
||||
<span><strong>+ 2 more signals</strong><small>Open full intervention queue</small></span>
|
||||
</button>
|
||||
<section class="rail-watch">
|
||||
<span class="eyebrow">Watchlist</span>
|
||||
<p><i></i><span><strong>7 long-running</strong><small>Longest: 47 minutes</small></span></p>
|
||||
<p><i></i><span><strong>3 quiet terminals</strong><small>No output for 10m+</small></span></p>
|
||||
</section>
|
||||
<footer>
|
||||
<button><svg class="icon"><use href="#i-filter"></use></svg>Filters <b>3</b></button>
|
||||
<button><svg class="icon"><use href="#i-layout"></use></svg>Group by project</button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<section class="fleet-stage">
|
||||
<header class="stage-header">
|
||||
<div><span class="eyebrow">Live fleet topology</span><strong>Everything running, one field of view</strong></div>
|
||||
<div class="legend">
|
||||
<span><i class="needs"></i>Needs you <b>9</b></span>
|
||||
<span><i class="active"></i>Working <b>34</b></span>
|
||||
<span><i></i>Tracking <b>71</b></span>
|
||||
<span><i class="done"></i>Recent <b>54</b></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="radar-field">
|
||||
<div class="project-axis axis-a"><span>ORCA · 112</span></div>
|
||||
<div class="project-axis axis-b"><span>MOBILE · 39</span></div>
|
||||
<div class="project-axis axis-c"><span>RELAY + OTHER · 17</span></div>
|
||||
<div class="radar-ring ring-one"><span>INTERVENE</span></div>
|
||||
<div class="radar-ring ring-two"><span>ACTIVE</span></div>
|
||||
<div class="radar-ring ring-three"><span>TRACKING</span></div>
|
||||
<div class="radar-ring ring-four"><span>RECENT</span></div>
|
||||
<div class="crosshair horizontal"></div><div class="crosshair vertical"></div>
|
||||
<div class="radar-sweep"></div>
|
||||
<button class="fleet-core"><strong>168</strong><span>agents accounted for</span><small>104 worktrees · 3 hosts</small></button>
|
||||
<div id="fleet-blips"></div>
|
||||
<button class="cluster-label label-review is-selected"><i></i><strong>review-11464</strong><span>3 agents · decision</span></button>
|
||||
<button class="cluster-label label-pr"><i></i><strong>pr-8783 review</strong><span>13 agents · 5 working</span></button>
|
||||
<button class="cluster-label label-mobile"><i></i><strong>mobile-9717</strong><span>12 agents · 2 waiting</span></button>
|
||||
<button class="cluster-label label-resume"><i></i><strong>codex-resume</strong><span>5 agents · 47m</span></button>
|
||||
<button class="cluster-label label-trust"><i></i><strong>trust-dialog</strong><span>5 agents · permission</span></button>
|
||||
<div class="zoom-controls"><button>−</button><span>Fleet</span><button>+</button><button>Fit all</button></div>
|
||||
<div class="minimap"><span></span><i></i><b></b><em></em></div>
|
||||
</div>
|
||||
<footer class="signal-ticker">
|
||||
<span class="ticker-live"><i></i>LIVE</span>
|
||||
<p><strong>Test verification finished</strong><span>review-11464 · 418 passed</span><time>22s</time></p>
|
||||
<p><strong>Agent resumed</strong><span>mobile-9717</span><time>1m</time></p>
|
||||
<p><strong>Review converged</strong><span>pr-9190</span><time>3m</time></p>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside class="response-panel">
|
||||
<header>
|
||||
<div><span class="signal-dot"></span><span class="eyebrow">Selected intervention</span></div>
|
||||
<h1>Runtime cleanup behavior</h1>
|
||||
<p>One response routes to three waiting agents.</p>
|
||||
<div class="panel-badges"><span>3 agents</span><span>2 worktrees</span><span>PR #11464</span></div>
|
||||
</header>
|
||||
<section class="decision-card">
|
||||
<span class="eyebrow">Decision</span>
|
||||
<strong>Should stale local agent rows survive an Orca restart?</strong>
|
||||
<p>Reviewers agree on remote and WSL history. They disagree only on local rows whose terminal no longer exists.</p>
|
||||
</section>
|
||||
<section class="evidence">
|
||||
<span class="eyebrow">Distilled evidence</span>
|
||||
<p><svg class="icon success"><use href="#i-check"></use></svg><span><strong>418 tests pass</strong><small>One restart fixture depends on policy.</small></span></p>
|
||||
<p><svg class="icon"><use href="#i-eye"></use></svg><span><strong>Three threads converged</strong><small>No implementation disagreement remains.</small></span></p>
|
||||
<button>Open affected diff <span>›</span></button>
|
||||
</section>
|
||||
<section class="recipients">
|
||||
<span class="eyebrow">Routes to</span>
|
||||
<div><i>R</i><span><strong>Review coordinator</strong><small>review-11464</small></span><b>primary</b></div>
|
||||
<div><i>S</i><span><strong>Security + cross-platform</strong><small>pr-8783</small></span><b>2 agents</b></div>
|
||||
</section>
|
||||
<form class="response-form" id="fleet-response-form">
|
||||
<div><span><strong>Respond once</strong> · 3 agents</span><button type="button" data-open-terminal><svg class="icon"><use href="#i-terminal"></use></svg>Inspect</button></div>
|
||||
<textarea id="fleet-response">Drop stale local rows after restart. Preserve remote and WSL history because reconnect can restore their backing sessions.</textarea>
|
||||
<footer><span>Context and source threads included</span><button><svg class="icon"><use href="#i-send"></use></svg>Send & next<kbd data-mac="⌘ ↵" data-other="Ctrl+Enter">⌘ ↵</kbd></button></footer>
|
||||
</form>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="terminal-backdrop" id="fleet-terminal" aria-hidden="true">
|
||||
<section class="terminal-popover" role="dialog" aria-modal="true" aria-label="Agent terminal">
|
||||
<header><div><i></i><span><strong>Review coordinator</strong><small>review-11464 · live terminal</small></span></div><button><svg class="icon"><use href="#i-expand"></use></svg></button><button data-close-terminal><svg class="icon"><use href="#i-close"></use></svg></button></header>
|
||||
<nav><button class="is-active">Agent</button><button>Tests</button><button>Git</button><span>LIVE</span></nav>
|
||||
<pre><span class="muted">Review complete. One product decision remains.</span>
|
||||
|
||||
<span class="success">●</span> Remote and WSL history should persist across reconnects.
|
||||
<span class="success">●</span> Local terminal <b>term_83ca</b> no longer exists after restart.
|
||||
|
||||
The remaining choice is whether to retain its historical agent row.
|
||||
Dropping it matches the existing local cleanup contract.
|
||||
|
||||
<span class="waiting">Waiting for your direction…</span></pre>
|
||||
<footer><span>Your fleet position and response draft are preserved.</span><button data-close-terminal>Return to command radar <kbd>Esc</kbd></button></footer>
|
||||
</section>
|
||||
</div>
|
||||
<div class="sent-toast" id="fleet-toast"><svg class="icon"><use href="#i-zap"></use></svg><span><strong>Three agents unblocked</strong>Advancing to the next intervention…</span></div>
|
||||
<script src="./fleet-command-center.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const fleetBlips = document.querySelector('#fleet-blips')
|
||||
const terminal = document.querySelector('#fleet-terminal')
|
||||
const responseForm = document.querySelector('#fleet-response-form')
|
||||
const toast = document.querySelector('#fleet-toast')
|
||||
|
||||
document.querySelectorAll('[data-mac][data-other]').forEach((label) => {
|
||||
label.textContent = isMac ? label.dataset.mac : label.dataset.other
|
||||
})
|
||||
|
||||
if (fleetBlips) {
|
||||
const groups = [
|
||||
{ count: 9, min: 13, max: 24, className: 'needs' },
|
||||
{ count: 34, min: 26, max: 43, className: 'active' },
|
||||
{ count: 71, min: 45, max: 66, className: '' },
|
||||
{ count: 54, min: 68, max: 88, className: 'done' }
|
||||
]
|
||||
let seed = 29
|
||||
const random = () => {
|
||||
seed = (seed * 9301 + 49297) % 233280
|
||||
return seed / 233280
|
||||
}
|
||||
groups.forEach((group) => {
|
||||
for (let index = 0; index < group.count; index += 1) {
|
||||
const angle = random() * Math.PI * 2
|
||||
const radius = group.min + random() * (group.max - group.min)
|
||||
const blip = document.createElement('i')
|
||||
blip.className = group.className
|
||||
blip.style.left = `${50 + Math.cos(angle) * radius * 0.5}%`
|
||||
blip.style.top = `${50 + Math.sin(angle) * radius * 0.5}%`
|
||||
fleetBlips.append(blip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setTerminalOpen(open) {
|
||||
if (!terminal) return
|
||||
terminal.classList.toggle('is-open', open)
|
||||
terminal.setAttribute('aria-hidden', String(!open))
|
||||
if (open) terminal.querySelector('[data-close-terminal]')?.focus()
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-open-terminal]').forEach((button) => {
|
||||
button.addEventListener('click', () => setTerminalOpen(true))
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-close-terminal]').forEach((button) => {
|
||||
button.addEventListener('click', () => setTerminalOpen(false))
|
||||
})
|
||||
|
||||
terminal?.addEventListener('click', (event) => {
|
||||
if (event.target === terminal) setTerminalOpen(false)
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && terminal?.classList.contains('is-open')) setTerminalOpen(false)
|
||||
const usesPlatformModifier = isMac ? event.metaKey : event.ctrlKey
|
||||
if (event.key === 'Enter' && usesPlatformModifier && !terminal?.classList.contains('is-open')) {
|
||||
responseForm?.requestSubmit()
|
||||
}
|
||||
})
|
||||
|
||||
responseForm?.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
toast?.classList.add('is-visible')
|
||||
window.setTimeout(() => toast?.classList.remove('is-visible'), 2200)
|
||||
})
|
||||
|
||||
document.querySelectorAll('.cluster-label').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
document.querySelectorAll('.cluster-label').forEach((item) => item.classList.remove('is-selected'))
|
||||
button.classList.add('is-selected')
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,202 @@
|
||||
@font-face {
|
||||
font-family: Geist;
|
||||
src: url("../src/renderer/src/assets/fonts/Geist-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #fff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #fff;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--border: #e5e5e5;
|
||||
--ring: #a1a1a1;
|
||||
--primary: #171717;
|
||||
--primary-foreground: #fafafa;
|
||||
--status-success: #15803d;
|
||||
--attention: #f59e0b;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #171717;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #404040;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--ring: #737373;
|
||||
--primary: #e5e5e5;
|
||||
--primary-foreground: #171717;
|
||||
--status-success: #86efac;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; overflow: hidden; background: var(--background); color: var(--foreground); font: 14px/1.45 Geist, sans-serif; letter-spacing: .01em; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { color: inherit; cursor: pointer; }
|
||||
.svg-defs { position: absolute; width: 0; height: 0; }
|
||||
.icon { width: 15px; height: 15px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.eyebrow { color: var(--muted-foreground); font-size: 9px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; }
|
||||
kbd { padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; background: var(--muted); color: var(--muted-foreground); font-size: 8px; white-space: nowrap; }
|
||||
.rings-shell { min-width: 1280px; height: 100vh; min-height: 780px; overflow: hidden; }
|
||||
|
||||
.topbar { height: 62px; display: flex; align-items: center; gap: 13px; padding: 0 17px; border-bottom: 1px solid var(--border); }
|
||||
.orca-mark { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 8px; background: var(--primary); color: var(--primary-foreground); font-weight: 800; }
|
||||
.title { min-width: 88px; display: flex; flex-direction: column; line-height: 1.15; }
|
||||
.title strong { font-size: 16px; }
|
||||
.title span { color: var(--muted-foreground); font-size: 9px; }
|
||||
.fleet-total { height: 32px; display: grid; grid-template-columns: auto auto 6px; align-items: center; gap: 5px; padding: 0 9px; border-radius: 7px; background: var(--muted); }
|
||||
.fleet-total strong { font-size: 14px; }
|
||||
.fleet-total span { color: var(--muted-foreground); font-size: 8px; text-transform: uppercase; }
|
||||
.fleet-total i { width: 5px; height: 5px; border-radius: 50%; background: var(--status-success); box-shadow: 0 0 6px color-mix(in srgb, var(--status-success) 60%, transparent); }
|
||||
.topbar nav { height: 33px; display: flex; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.topbar nav button { padding: 0 9px; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); font-size: 9px; }
|
||||
.topbar nav button:hover, .topbar nav button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.search { width: min(280px, 19vw); height: 32px; display: flex; align-items: center; gap: 7px; margin-left: auto; padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--muted) 55%, transparent); color: var(--muted-foreground); }
|
||||
.search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: 10px; }
|
||||
.orchestrator { height: 32px; display: flex; align-items: center; gap: 6px; padding: 0 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); font-size: 9px; }
|
||||
.orchestrator span { width: 6px; height: 6px; border: 1px solid var(--muted-foreground); border-radius: 50%; box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted-foreground) 10%, transparent); }
|
||||
|
||||
.rings-grid { height: calc(100vh - 62px); display: grid; grid-template-columns: 220px minmax(720px, 1fr) 320px; }
|
||||
.layers-panel, .inspector { min-height: 0; background: color-mix(in srgb, var(--card) 67%, var(--background)); }
|
||||
.layers-panel { display: flex; flex-direction: column; padding: 15px 11px 10px; border-right: 1px solid var(--border); }
|
||||
.layers-panel > header { padding: 0 4px 13px; border-bottom: 1px solid var(--border); }
|
||||
.layers-panel h1 { margin: 4px 0 2px; font-size: 13px; }
|
||||
.layers-panel > header p { margin: 0; color: var(--muted-foreground); font-size: 8px; }
|
||||
.layers-panel section { margin: 12px 3px 0; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||
.layers-panel section.project-list { padding-top: 0; border-top: 0; }
|
||||
.project-list > span, .state-legend > span, .size-legend > span { display: block; margin-bottom: 7px; }
|
||||
.project-list button { width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 7px; padding: 7px; border: 1px solid transparent; border-radius: 6px; background: transparent; text-align: left; }
|
||||
.project-list button:hover, .project-list button.is-active { border-color: var(--border); background: var(--muted); }
|
||||
.project-list button > i { width: 7px; height: 7px; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.project-list button.is-active > i { border-color: var(--attention); box-shadow: 0 0 6px color-mix(in srgb, var(--attention) 55%, transparent); }
|
||||
.project-list button span { min-width: 0; display: flex; flex-direction: column; }
|
||||
.project-list strong { font-size: 9px; }
|
||||
.project-list small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.project-list b { font-size: 9px; }
|
||||
.state-legend p { display: grid; grid-template-columns: 7px 1fr auto; align-items: center; gap: 7px; margin: 9px 2px; color: var(--muted-foreground); font-size: 8px; }
|
||||
.state-legend p > i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted-foreground); opacity: .45; }
|
||||
.state-legend p > i.needs { background: var(--attention); opacity: 1; box-shadow: 0 0 6px var(--attention); }
|
||||
.state-legend p > i.working { border: 1px solid var(--muted-foreground); background: transparent; opacity: 1; }
|
||||
.state-legend p > i.recent { background: var(--status-success); opacity: .65; }
|
||||
.state-legend b { color: var(--foreground); font-size: 9px; }
|
||||
.size-legend > div { height: 32px; display: flex; align-items: center; justify-content: space-around; padding: 0 8px; border-radius: 6px; background: var(--muted); }
|
||||
.size-legend > div i { display: block; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.size-legend > div i:nth-child(1) { width: 5px; height: 5px; }.size-legend > div i:nth-child(2) { width: 8px; height: 8px; }.size-legend > div i:nth-child(3) { width: 12px; height: 12px; }.size-legend > div i:nth-child(4) { width: 17px; height: 17px; }
|
||||
.size-legend p { display: flex; justify-content: space-between; margin: 5px 0 2px; color: var(--muted-foreground); font-size: 7px; }
|
||||
.size-legend small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.canvas-help p { display: flex; justify-content: space-between; margin: 7px 1px; font-size: 8px; }
|
||||
.canvas-help p span { color: var(--muted-foreground); }
|
||||
.layers-panel > footer { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; margin-top: auto; }
|
||||
.layers-panel > footer button { height: 30px; display: flex; align-items: center; gap: 5px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); color: var(--muted-foreground); font-size: 8px; }
|
||||
.layers-panel > footer b { margin-left: auto; }
|
||||
|
||||
.canvas-column { min-width: 0; min-height: 0; display: grid; grid-template-rows: 56px minmax(0, 1fr); }
|
||||
.canvas-header { display: flex; align-items: center; padding: 0 13px; border-bottom: 1px solid var(--border); }
|
||||
.canvas-header > div:first-child { display: flex; flex-direction: column; }
|
||||
.canvas-header strong { font-size: 11px; }
|
||||
.canvas-header > div:last-child { display: flex; gap: 3px; margin-left: auto; }
|
||||
.canvas-header button { height: 25px; padding: 0 7px; border: 1px solid var(--border); border-radius: 5px; background: transparent; color: var(--muted-foreground); font-size: 7px; }
|
||||
.canvas-header button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.rings-canvas { position: relative; min-height: 0; overflow: hidden; background-image: radial-gradient(color-mix(in srgb, var(--muted-foreground) 16%, transparent) .55px, transparent .55px); background-size: 20px 20px; }
|
||||
.rings-canvas::before { position: absolute; inset: 0; background: radial-gradient(circle at 45% 48%, color-mix(in srgb, var(--muted) 22%, transparent), transparent 68%); content: ""; }
|
||||
#fleet-map { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.project-ring { fill: color-mix(in srgb, var(--card) 18%, transparent); stroke: color-mix(in srgb, var(--muted-foreground) 35%, transparent); stroke-width: 1.2; vector-effect: non-scaling-stroke; }
|
||||
.project-orca .project-ring { stroke: color-mix(in srgb, var(--ring) 70%, transparent); }
|
||||
.project-name { fill: var(--foreground); font-size: 9px; font-weight: 700; letter-spacing: .07em; }
|
||||
.project-count { fill: var(--muted-foreground); font-size: 6px; letter-spacing: .05em; }
|
||||
.worktree-ring { fill: color-mix(in srgb, var(--card) 42%, transparent); stroke: color-mix(in srgb, var(--muted-foreground) 32%, transparent); stroke-width: 1; vector-effect: non-scaling-stroke; transition: .16s ease-out; }
|
||||
.worktree:hover .worktree-ring { fill: color-mix(in srgb, var(--card) 68%, transparent); stroke: var(--ring); }
|
||||
.worktree.selected .worktree-ring { fill: color-mix(in srgb, var(--attention) 4%, var(--card)); stroke: color-mix(in srgb, var(--attention) 60%, transparent); stroke-width: 1.5; }
|
||||
.worktree-name { fill: var(--foreground); font-size: 6.5px; font-weight: 650; letter-spacing: .045em; text-anchor: middle; }
|
||||
.worktree-count { fill: var(--muted-foreground); font-size: 5.5px; text-anchor: middle; }
|
||||
.agent-node { cursor: pointer; vector-effect: non-scaling-stroke; transition: opacity .12s ease-out, stroke-width .12s ease-out; }
|
||||
.agent-node.tracking { fill: var(--muted-foreground); opacity: .4; }
|
||||
.agent-node.recent { fill: var(--status-success); opacity: .55; }
|
||||
.agent-node.working { fill: var(--background); stroke: var(--muted-foreground); stroke-width: 1; opacity: .95; animation: node-pulse 1.9s ease-in-out infinite; }
|
||||
.agent-node.needs { fill: var(--attention); stroke: color-mix(in srgb, var(--attention) 38%, var(--foreground)); stroke-width: 1; filter: drop-shadow(0 0 4px var(--attention)); }
|
||||
.agent-node:hover, .agent-node.is-hovered { opacity: 1; stroke: var(--foreground); stroke-width: 2; filter: drop-shadow(0 0 5px currentColor); }
|
||||
.terminal-preview { position: absolute; z-index: 10; top: 16%; left: 52%; width: 280px; overflow: hidden; border: 1px solid var(--ring); border-radius: 9px; background: color-mix(in srgb, var(--card) 96%, transparent); box-shadow: 0 10px 24px rgb(0 0 0 / .18); opacity: 0; pointer-events: none; transform: translateY(4px); transition: .12s ease-out; }
|
||||
.terminal-preview.is-visible, .terminal-preview.is-demo { opacity: 1; transform: translateY(0); }
|
||||
.terminal-preview header { height: 40px; display: grid; grid-template-columns: 7px 1fr auto; align-items: center; gap: 7px; padding: 0 9px; border-bottom: 1px solid var(--border); }
|
||||
.preview-state { width: 7px; height: 7px; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.terminal-preview header > div { min-width: 0; display: flex; flex-direction: column; }
|
||||
.terminal-preview header strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.terminal-preview header small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.terminal-preview pre { margin: 0; padding: 11px; color: var(--primary); font: 8px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
|
||||
.terminal-preview pre span { color: var(--status-success); }
|
||||
.terminal-preview pre b { color: var(--foreground); font-weight: 600; }
|
||||
.terminal-preview footer { height: 29px; display: flex; align-items: center; padding: 0 9px; border-top: 1px solid var(--border); color: var(--muted-foreground); font-size: 7px; }
|
||||
.terminal-preview footer strong { margin-left: auto; color: var(--status-success); font-size: 7px; text-transform: uppercase; }
|
||||
.canvas-controls { position: absolute; z-index: 7; bottom: 12px; left: 12px; display: flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); }
|
||||
.canvas-controls button, .canvas-controls span { height: 25px; display: flex; align-items: center; gap: 4px; padding: 0 7px; border: 0; border-radius: 4px; background: transparent; color: var(--muted-foreground); font-size: 7px; }
|
||||
.canvas-controls button:hover { background: var(--muted); }
|
||||
.minimap { position: absolute; z-index: 7; right: 12px; bottom: 12px; width: 95px; height: 60px; border: 1px solid var(--border); border-radius: 7px; background: var(--card); opacity: .88; }
|
||||
.minimap::before { position: absolute; top: 8px; left: 10px; width: 44px; height: 44px; border: 1px solid var(--ring); border-radius: 50%; content: ""; }
|
||||
.minimap i, .minimap span, .minimap b { position: absolute; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.minimap i { top: 11px; right: 9px; width: 25px; height: 25px; }.minimap span { right: 13px; bottom: 7px; width: 18px; height: 18px; }.minimap b { top: 22px; left: 25px; width: 7px; height: 7px; border-color: var(--attention); }
|
||||
|
||||
.inspector { display: flex; min-height: 0; flex-direction: column; padding: 15px; border-left: 1px solid var(--border); overflow: auto; }
|
||||
.inspector > header h2 { margin: 5px 0 1px; font-size: 15px; }
|
||||
.inspector > header p { margin: 0; color: var(--muted-foreground); font-size: 8px; }
|
||||
.worktree-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-top: 11px; }
|
||||
.worktree-stats span { display: flex; flex-direction: column; padding: 7px; border-radius: 6px; background: var(--muted); color: var(--muted-foreground); font-size: 7px; text-transform: uppercase; }
|
||||
.worktree-stats strong { color: var(--foreground); font-size: 13px; }
|
||||
.inspector > section { margin-top: 13px; padding-top: 13px; border-top: 1px solid var(--border); }
|
||||
.duration-outlier > div { display: grid; grid-template-columns: 27px 1fr; align-items: center; gap: 8px; margin: 9px 0; }
|
||||
.agent-sample { width: 25px; height: 25px; display: block; border: 1px solid var(--muted-foreground); border-radius: 50%; box-shadow: 0 0 0 5px color-mix(in srgb, var(--muted-foreground) 9%, transparent); }
|
||||
.duration-outlier p { display: flex; flex-direction: column; margin: 0; }
|
||||
.duration-outlier strong { font-size: 9px; }
|
||||
.duration-outlier small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.duration-outlier button { width: 100%; height: 29px; display: flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); font-size: 8px; }
|
||||
.decision strong { display: block; margin: 6px 0 4px; font-size: 10px; }
|
||||
.decision p { margin: 0; color: var(--muted-foreground); font-size: 8px; }
|
||||
.inspector form { flex: 0 0 auto; margin-top: auto; overflow: hidden; border: 1px solid var(--ring); border-radius: 9px; background: var(--card); box-shadow: 0 10px 24px rgb(0 0 0 / .18); }
|
||||
.inspector form > div { height: 32px; display: flex; align-items: center; padding: 0 8px; border-bottom: 1px solid var(--border); color: var(--muted-foreground); font-size: 8px; }
|
||||
.inspector form > div strong { color: var(--foreground); }
|
||||
.inspector form > div button { height: 24px; display: flex; align-items: center; gap: 4px; margin-left: auto; padding: 0 6px; border: 1px solid var(--border); border-radius: 5px; background: transparent; font-size: 7px; }
|
||||
.inspector form textarea { width: 100%; min-height: 92px; display: block; resize: none; padding: 9px; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: 9px; line-height: 1.55; }
|
||||
.inspector form footer { min-height: 37px; display: flex; align-items: center; padding: 5px 6px 5px 9px; border-top: 1px solid var(--border); }
|
||||
.inspector form footer > span { margin-right: auto; color: var(--muted-foreground); font-size: 7px; }
|
||||
.inspector form footer button { height: 27px; display: flex; align-items: center; gap: 4px; padding: 0 7px; border: 1px solid var(--primary); border-radius: 6px; background: var(--primary); color: var(--primary-foreground); font-size: 8px; }
|
||||
.inspector form footer button kbd { border-color: color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); color: inherit; }
|
||||
|
||||
.terminal-backdrop { position: fixed; z-index: 20; inset: 62px 0 0; display: none; align-items: center; justify-content: center; background: rgb(0 0 0 / .5); backdrop-filter: blur(2px); }
|
||||
.terminal-backdrop.is-open { display: flex; animation: fade-in .14s ease-out; }
|
||||
.terminal-popover { width: min(940px, 72vw); height: min(590px, 70vh); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--ring); border-radius: 12px; background: var(--background); box-shadow: 0 10px 24px rgb(0 0 0 / .18); }
|
||||
.terminal-popover > header { height: 46px; display: flex; align-items: center; gap: 4px; padding: 0 9px 0 14px; border-bottom: 1px solid var(--border); }
|
||||
.terminal-popover > header > div { display: flex; align-items: center; gap: 7px; margin-right: auto; }
|
||||
.terminal-popover > header i { width: 7px; height: 7px; border: 1px solid var(--muted-foreground); border-radius: 50%; }
|
||||
.terminal-popover > header span { display: flex; flex-direction: column; }
|
||||
.terminal-popover > header strong { font-size: 9px; }.terminal-popover > header small { color: var(--muted-foreground); font-size: 7px; }
|
||||
.terminal-popover > header button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 5px; background: transparent; color: var(--muted-foreground); }
|
||||
.terminal-popover > nav { height: 33px; display: flex; align-items: center; gap: 2px; padding: 0 10px; border-bottom: 1px solid var(--border); }
|
||||
.terminal-popover > nav button { height: 23px; padding: 0 8px; border: 0; border-radius: 4px; background: transparent; color: var(--muted-foreground); font-size: 7px; }
|
||||
.terminal-popover > nav button.is-active { background: var(--muted); color: var(--foreground); }
|
||||
.terminal-popover > nav span { margin-left: auto; color: var(--status-success); font-size: 7px; }
|
||||
.terminal-popover pre { flex: 1; margin: 0; padding: 25px; color: var(--primary); font: 12px/1.8 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
|
||||
.terminal-popover pre .muted { color: var(--ring); }.terminal-popover pre .success { color: var(--status-success); }.terminal-popover pre .waiting { color: var(--attention); }
|
||||
.terminal-popover > footer { height: 45px; display: flex; align-items: center; padding: 0 10px 0 14px; border-top: 1px solid var(--border); }
|
||||
.terminal-popover > footer > span { margin-right: auto; color: var(--ring); font-size: 7px; }
|
||||
.terminal-popover > footer button { height: 28px; padding: 0 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--card); font-size: 8px; }
|
||||
.sent-toast { position: fixed; z-index: 30; right: 18px; bottom: 18px; display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--status-success) 25%, transparent); border-radius: 8px; background: var(--card); box-shadow: 0 10px 24px rgb(0 0 0 / .18); opacity: 0; pointer-events: none; transform: translateY(8px); transition: .16s ease-out; }
|
||||
.sent-toast.is-visible { opacity: 1; transform: translateY(0); }
|
||||
.sent-toast .icon { color: var(--status-success); }
|
||||
.sent-toast span { display: flex; flex-direction: column; color: var(--muted-foreground); font-size: 7px; }
|
||||
.sent-toast strong { color: var(--foreground); font-size: 9px; }
|
||||
|
||||
@keyframes node-pulse { 50% { opacity: .35; } }
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
@media (max-width: 1420px) {
|
||||
.rings-grid { grid-template-columns: 205px minmax(680px, 1fr) 300px; }
|
||||
.topbar nav button { padding: 0 7px; }
|
||||
.search { width: 205px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orca Fleet Rings</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="./fleet-rings.css" />
|
||||
</head>
|
||||
<body class="dark">
|
||||
<svg class="svg-defs" aria-hidden="true">
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></symbol>
|
||||
<symbol id="i-terminal" viewBox="0 0 24 24"><path d="m4 6 5 5-5 5M12 18h8"></path></symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M4 5h16M7 12h10M10 19h4"></path></symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4zM22 2 11 13"></path></symbol>
|
||||
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"></path></symbol>
|
||||
<symbol id="i-expand" viewBox="0 0 24 24"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5"></path></symbol>
|
||||
<symbol id="i-focus" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"></path><circle cx="12" cy="12" r="3"></circle></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"></path></symbol>
|
||||
</svg>
|
||||
|
||||
<main class="rings-shell">
|
||||
<header class="topbar">
|
||||
<div class="orca-mark">O</div>
|
||||
<div class="title"><strong>Agents</strong><span>Fleet rings</span></div>
|
||||
<div class="fleet-total"><strong>168</strong><span>accounted for</span><i></i></div>
|
||||
<nav><button class="is-active">Rings</button><button>Matrix</button><button>Timeline</button><button>History</button></nav>
|
||||
<label class="search"><svg class="icon"><use href="#i-search"></use></svg><input placeholder="Find any agent…" /><kbd data-mac="⌘ K" data-other="Ctrl+K">⌘ K</kbd></label>
|
||||
<button class="orchestrator"><span></span>Ask Orchestrator<kbd data-mac="⌘ ⇧ O" data-other="Ctrl+Shift+O">⌘ ⇧ O</kbd></button>
|
||||
</header>
|
||||
|
||||
<section class="rings-grid">
|
||||
<aside class="layers-panel">
|
||||
<header><span class="eyebrow">Canvas layers</span><h1>Project → worktree → agent</h1><p>Every agent has a stable place in its ownership tree.</p></header>
|
||||
<section class="project-list">
|
||||
<span class="eyebrow">Projects</span>
|
||||
<button class="is-active"><i></i><span><strong>Orca</strong><small>58 worktrees</small></span><b>112</b></button>
|
||||
<button><i></i><span><strong>Mobile</strong><small>13 worktrees</small></span><b>39</b></button>
|
||||
<button><i></i><span><strong>Relay + other</strong><small>7 worktrees</small></span><b>17</b></button>
|
||||
</section>
|
||||
<section class="state-legend">
|
||||
<span class="eyebrow">Agent state</span>
|
||||
<p><i class="needs"></i><span>Needs you</span><b>9</b></p>
|
||||
<p><i class="working"></i><span>Working</span><b>34</b></p>
|
||||
<p><i></i><span>Tracking</span><b>71</b></p>
|
||||
<p><i class="recent"></i><span>Recent</span><b>54</b></p>
|
||||
</section>
|
||||
<section class="size-legend">
|
||||
<span class="eyebrow">Agent size</span>
|
||||
<div><i></i><i></i><i></i><i></i></div>
|
||||
<p><span>Just started</span><span>Working longer</span></p>
|
||||
<small>Logarithmic scale · capped after 60m</small>
|
||||
</section>
|
||||
<section class="canvas-help">
|
||||
<p><strong>Hover</strong><span>Preview live terminal</span></p>
|
||||
<p><strong>Click</strong><span>Pin agent and respond</span></p>
|
||||
<p><strong>Double-click</strong><span>Zoom into worktree</span></p>
|
||||
</section>
|
||||
<footer><button><svg class="icon"><use href="#i-filter"></use></svg>Filters <b>3</b></button><button><svg class="icon"><use href="#i-focus"></use></svg>Fit fleet</button></footer>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-column">
|
||||
<header class="canvas-header">
|
||||
<div><span class="eyebrow">Live containment map</span><strong>Ownership, activity, and time in one canvas</strong></div>
|
||||
<div><button class="is-active">All hosts</button><button>Local</button><button>SSH</button><button>WSL</button></div>
|
||||
</header>
|
||||
<div class="rings-canvas" id="rings-canvas">
|
||||
<svg id="fleet-map" viewBox="0 0 1050 800" preserveAspectRatio="xMidYMid meet" aria-label="Nested project, worktree, and agent map">
|
||||
<g class="project project-orca">
|
||||
<circle class="project-ring" cx="330" cy="385" r="325"></circle>
|
||||
<text class="project-name" x="95" y="105">ORCA</text><text class="project-count" x="95" y="121">112 AGENTS · 58 WORKTREES</text>
|
||||
</g>
|
||||
<g class="project project-mobile">
|
||||
<circle class="project-ring" cx="815" cy="250" r="200"></circle>
|
||||
<text class="project-name" x="690" y="90">MOBILE</text><text class="project-count" x="690" y="106">39 AGENTS · 13 WORKTREES</text>
|
||||
</g>
|
||||
<g class="project project-relay">
|
||||
<circle class="project-ring" cx="820" cy="625" r="150"></circle>
|
||||
<text class="project-name" x="727" y="500">RELAY + OTHER</text><text class="project-count" x="727" y="516">17 AGENTS · 7 WORKTREES</text>
|
||||
</g>
|
||||
|
||||
<g class="worktree" data-name="+ 47 worktrees" data-project="Orca" data-count="74" data-needs="2" data-working="14" data-recent="25" data-cx="315" data-cy="400" data-radius="140">
|
||||
<circle class="worktree-ring" cx="315" cy="400" r="140"></circle><text class="worktree-name" x="315" y="269">+ 47 WORKTREES</text><text class="worktree-count" x="315" y="283">74 AGENTS</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree selected" data-name="pr-8783-internal-review" data-project="Orca" data-count="13" data-needs="3" data-working="5" data-recent="2" data-cx="525" data-cy="245" data-radius="78">
|
||||
<circle class="worktree-ring" cx="525" cy="245" r="78"></circle><text class="worktree-name" x="525" y="178">PR-8783 REVIEW</text><text class="worktree-count" x="525" y="191">13 AGENTS</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="pr-9190-internal-review" data-project="Orca" data-count="12" data-needs="0" data-working="4" data-recent="3" data-cx="535" data-cy="460" data-radius="78">
|
||||
<circle class="worktree-ring" cx="535" cy="460" r="78"></circle><text class="worktree-name" x="535" y="393">PR-9190 REVIEW</text><text class="worktree-count" x="535" y="406">12 AGENTS</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="review-11464" data-project="Orca" data-count="3" data-needs="1" data-working="2" data-recent="0" data-cx="300" data-cy="170" data-radius="42">
|
||||
<circle class="worktree-ring" cx="300" cy="170" r="42"></circle><text class="worktree-name" x="300" y="131">REVIEW-11464</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="orca-trust-dialog" data-project="Orca" data-count="5" data-needs="1" data-working="2" data-recent="1" data-cx="140" data-cy="310" data-radius="50">
|
||||
<circle class="worktree-ring" cx="140" cy="310" r="50"></circle><text class="worktree-name" x="140" y="264">TRUST DIALOG</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="codex-resume-argv-drop" data-project="Orca" data-count="5" data-needs="0" data-working="1" data-recent="2" data-cx="170" data-cy="555" data-radius="52">
|
||||
<circle class="worktree-ring" cx="170" cy="555" r="52"></circle><text class="worktree-name" x="170" y="507">CODEX RESUME</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
|
||||
<g class="worktree" data-name="mobile-9717-dup-sessions" data-project="Mobile" data-count="12" data-needs="1" data-working="3" data-recent="3" data-cx="760" data-cy="220" data-radius="65">
|
||||
<circle class="worktree-ring" cx="760" cy="220" r="65"></circle><text class="worktree-name" x="760" y="162">MOBILE-9717</text><text class="worktree-count" x="760" y="175">12 AGENTS</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="mobile-6600-save-host" data-project="Mobile" data-count="6" data-needs="0" data-working="1" data-recent="2" data-cx="870" data-cy="180" data-radius="50">
|
||||
<circle class="worktree-ring" cx="870" cy="180" r="50"></circle><text class="worktree-name" x="870" y="136">SAVE HOST</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="mobile-ime-6995" data-project="Mobile" data-count="5" data-needs="0" data-working="1" data-recent="2" data-cx="885" data-cy="290" data-radius="45">
|
||||
<circle class="worktree-ring" cx="885" cy="290" r="45"></circle><text class="worktree-name" x="885" y="250">IME-6995</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="+ 9 worktrees" data-project="Mobile" data-count="16" data-needs="0" data-working="1" data-recent="6" data-cx="750" data-cy="360" data-radius="70">
|
||||
<circle class="worktree-ring" cx="750" cy="360" r="70"></circle><text class="worktree-name" x="750" y="297">+ 9 WORKTREES</text><text class="worktree-count" x="750" y="310">16 AGENTS</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
|
||||
<g class="worktree" data-name="connection recovery" data-project="Relay" data-count="8" data-needs="1" data-working="0" data-recent="3" data-cx="755" data-cy="625" data-radius="60">
|
||||
<circle class="worktree-ring" cx="755" cy="625" r="60"></circle><text class="worktree-name" x="755" y="570">CONNECTION</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
<g class="worktree" data-name="docs + tooling" data-project="Other" data-count="9" data-needs="0" data-working="0" data-recent="5" data-cx="885" data-cy="625" data-radius="60">
|
||||
<circle class="worktree-ring" cx="885" cy="625" r="60"></circle><text class="worktree-name" x="885" y="570">DOCS + TOOLING</text><g class="agent-layer"></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="terminal-preview is-demo" id="terminal-preview">
|
||||
<header><span class="preview-state"></span><div><strong>Cross-platform reviewer</strong><small>pr-8783-internal-review · working 42m</small></div><kbd>hover</kbd></header>
|
||||
<pre><span>›</span> Running the restart matrix across native,
|
||||
WSL, and SSH hosts…
|
||||
|
||||
<b>418 tests passed</b>
|
||||
Waiting on cleanup retention policy.</pre>
|
||||
<footer><span>Click agent to pin terminal</span><strong>live</strong></footer>
|
||||
</div>
|
||||
<div class="canvas-controls"><button>−</button><span>Fleet</span><button>+</button><button><svg class="icon"><use href="#i-focus"></use></svg>Fit</button></div>
|
||||
<div class="minimap"><i></i><span></span><b></b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="inspector">
|
||||
<header><span class="eyebrow">Selected worktree</span><h2>pr-8783-internal-review</h2><p>13 agents inside this ring</p></header>
|
||||
<div class="worktree-stats"><span><strong>3</strong>need you</span><span><strong>5</strong>working</span><span><strong>2</strong>recent</span></div>
|
||||
<section class="duration-outlier">
|
||||
<span class="eyebrow">Largest agent</span>
|
||||
<div><i class="agent-sample"></i><p><strong>Cross-platform reviewer</strong><small>Working 42m · output 18s ago</small></p></div>
|
||||
<button data-open-terminal><svg class="icon"><use href="#i-terminal"></use></svg>Open live terminal</button>
|
||||
</section>
|
||||
<section class="decision">
|
||||
<span class="eyebrow">Decision blocking this ring</span>
|
||||
<strong>Should stale local agent rows survive an Orca restart?</strong>
|
||||
<p>Three review agents agree on remote history but need direction for local rows.</p>
|
||||
</section>
|
||||
<form id="rings-response">
|
||||
<div><span><strong>Respond once</strong> · 3 agents</span><button type="button" data-open-terminal><svg class="icon"><use href="#i-terminal"></use></svg>Inspect</button></div>
|
||||
<textarea>Drop stale local rows after restart. Preserve remote and WSL history because reconnect can restore their backing sessions.</textarea>
|
||||
<footer><span>Routes with source context</span><button><svg class="icon"><use href="#i-send"></use></svg>Send & next<kbd data-mac="⌘ ↵" data-other="Ctrl+Enter">⌘ ↵</kbd></button></footer>
|
||||
</form>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="terminal-backdrop" id="rings-terminal" aria-hidden="true">
|
||||
<section class="terminal-popover" role="dialog" aria-modal="true" aria-label="Agent terminal">
|
||||
<header><div><i></i><span><strong>Cross-platform reviewer</strong><small>pr-8783-internal-review · working 42m</small></span></div><button><svg class="icon"><use href="#i-expand"></use></svg></button><button data-close-terminal><svg class="icon"><use href="#i-close"></use></svg></button></header>
|
||||
<nav><button class="is-active">Agent</button><button>Tests</button><button>Git</button><span>LIVE</span></nav>
|
||||
<pre><span class="muted">Restart matrix: native · WSL · SSH</span>
|
||||
|
||||
<span class="success">●</span> Native cleanup behavior verified
|
||||
<span class="success">●</span> WSL reconnect history verified
|
||||
<span class="success">●</span> SSH relay history verified
|
||||
|
||||
418 tests passed. The implementation is ready.
|
||||
|
||||
<span class="waiting">Waiting for local retention policy…</span></pre>
|
||||
<footer><span>Your canvas position and response draft are preserved.</span><button data-close-terminal>Return to fleet rings <kbd>Esc</kbd></button></footer>
|
||||
</section>
|
||||
</div>
|
||||
<div class="sent-toast" id="rings-toast"><svg class="icon"><use href="#i-check"></use></svg><span><strong>Direction sent to 3 agents</strong>The worktree ring is unblocked.</span></div>
|
||||
<script src="./fleet-rings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
const svgNamespace = 'http://www.w3.org/2000/svg'
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const canvas = document.querySelector('#rings-canvas')
|
||||
const preview = document.querySelector('#terminal-preview')
|
||||
const terminal = document.querySelector('#rings-terminal')
|
||||
const responseForm = document.querySelector('#rings-response')
|
||||
const toast = document.querySelector('#rings-toast')
|
||||
|
||||
document.querySelectorAll('[data-mac][data-other]').forEach((label) => {
|
||||
label.textContent = isMac ? label.dataset.mac : label.dataset.other
|
||||
})
|
||||
|
||||
function agentDuration(state, globalIndex, clusterIndex) {
|
||||
if (state === 'needs') return 28 + ((globalIndex * 7 + clusterIndex * 5) % 32)
|
||||
if (state === 'working') return 1 + ((globalIndex * 13 + clusterIndex * 3) % 47)
|
||||
if (state === 'recent') return 2 + ((globalIndex * 5 + clusterIndex) % 24)
|
||||
return 1 + ((globalIndex * 3 + clusterIndex * 7) % 18)
|
||||
}
|
||||
|
||||
function agentState(index, needs, working, recent) {
|
||||
if (index < needs) return 'needs'
|
||||
if (index < needs + working) return 'working'
|
||||
if (index < needs + working + recent) return 'recent'
|
||||
return 'tracking'
|
||||
}
|
||||
|
||||
function previewAgent(node, event) {
|
||||
if (!preview || !canvas) return
|
||||
const state = node.dataset.state
|
||||
const duration = node.dataset.duration
|
||||
preview.classList.remove('is-demo')
|
||||
preview.classList.add('is-visible')
|
||||
preview.querySelector('header strong').textContent = node.dataset.agentName
|
||||
preview.querySelector('header small').textContent = `${node.dataset.worktree} · ${state} ${duration}m`
|
||||
preview.querySelector('pre').textContent = state === 'needs'
|
||||
? '› Review complete. Implementation is ready.\n\nWaiting for cleanup retention policy.'
|
||||
: `› Live output from ${node.dataset.worktree}\n\n${duration} minutes active · no stall detected.`
|
||||
const bounds = canvas.getBoundingClientRect()
|
||||
const left = Math.min(event.clientX - bounds.left + 14, bounds.width - 294)
|
||||
const top = Math.min(event.clientY - bounds.top + 14, bounds.height - 150)
|
||||
preview.style.left = `${Math.max(8, left)}px`
|
||||
preview.style.top = `${Math.max(8, top)}px`
|
||||
node.classList.add('is-hovered')
|
||||
}
|
||||
|
||||
let globalIndex = 0
|
||||
document.querySelectorAll('.worktree').forEach((worktree, clusterIndex) => {
|
||||
const count = Number(worktree.dataset.count)
|
||||
const needs = Number(worktree.dataset.needs)
|
||||
const working = Number(worktree.dataset.working)
|
||||
const recent = Number(worktree.dataset.recent)
|
||||
const centerX = Number(worktree.dataset.cx)
|
||||
const centerY = Number(worktree.dataset.cy)
|
||||
const clusterRadius = Number(worktree.dataset.radius)
|
||||
const layer = worktree.querySelector('.agent-layer')
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const state = agentState(index, needs, working, recent)
|
||||
const duration = agentDuration(state, globalIndex, clusterIndex)
|
||||
const nodeRadius = 2.3 + Math.min(5.8, Math.log2(duration + 1) * .92)
|
||||
const angle = index * 2.399963 + clusterIndex * .71
|
||||
const orbit = count === 1 ? 0 : 8 + Math.sqrt((index + .35) / count) * (clusterRadius - 17)
|
||||
const node = document.createElementNS(svgNamespace, 'circle')
|
||||
node.setAttribute('cx', String(centerX + Math.cos(angle) * orbit))
|
||||
node.setAttribute('cy', String(centerY + Math.sin(angle) * orbit))
|
||||
node.setAttribute('r', nodeRadius.toFixed(2))
|
||||
node.setAttribute('class', `agent-node ${state}`)
|
||||
node.setAttribute('tabindex', '0')
|
||||
node.setAttribute('aria-label', `${worktree.dataset.name} agent ${index + 1}, ${state}, ${duration} minutes`)
|
||||
node.dataset.state = state
|
||||
node.dataset.duration = String(duration)
|
||||
node.dataset.worktree = worktree.dataset.name
|
||||
node.dataset.agentName = index === 0 ? 'Review coordinator' : index === 1 ? 'Cross-platform reviewer' : `Agent ${String(globalIndex + 1).padStart(3, '0')}`
|
||||
node.addEventListener('pointerenter', (event) => previewAgent(node, event))
|
||||
node.addEventListener('pointermove', (event) => previewAgent(node, event))
|
||||
node.addEventListener('pointerleave', () => {
|
||||
node.classList.remove('is-hovered')
|
||||
preview?.classList.remove('is-visible')
|
||||
})
|
||||
node.addEventListener('click', () => setTerminalOpen(true))
|
||||
layer?.append(node)
|
||||
globalIndex += 1
|
||||
}
|
||||
})
|
||||
|
||||
function setTerminalOpen(open) {
|
||||
if (!terminal) return
|
||||
terminal.classList.toggle('is-open', open)
|
||||
terminal.setAttribute('aria-hidden', String(!open))
|
||||
if (open) terminal.querySelector('[data-close-terminal]')?.focus()
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-open-terminal]').forEach((button) => {
|
||||
button.addEventListener('click', () => setTerminalOpen(true))
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-close-terminal]').forEach((button) => {
|
||||
button.addEventListener('click', () => setTerminalOpen(false))
|
||||
})
|
||||
|
||||
terminal?.addEventListener('click', (event) => {
|
||||
if (event.target === terminal) setTerminalOpen(false)
|
||||
})
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && terminal?.classList.contains('is-open')) setTerminalOpen(false)
|
||||
const usesPlatformModifier = isMac ? event.metaKey : event.ctrlKey
|
||||
if (event.key === 'Enter' && usesPlatformModifier && !terminal?.classList.contains('is-open')) {
|
||||
responseForm?.requestSubmit()
|
||||
}
|
||||
})
|
||||
|
||||
responseForm?.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
toast?.classList.add('is-visible')
|
||||
window.setTimeout(() => toast?.classList.remove('is-visible'), 2200)
|
||||
})
|
||||
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,3 @@
|
||||
actual-agent-activity.js
|
||||
actual-agent-activity.json
|
||||
activity-lanes-actual-dark.png
|
||||
@@ -0,0 +1,68 @@
|
||||
# Global agent viewer alternatives
|
||||
|
||||
Open `index.html` directly, or serve the repository root:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 4173
|
||||
```
|
||||
|
||||
Then open `http://localhost:4173/agent-viewer-alternatives/`.
|
||||
|
||||
## Concepts
|
||||
|
||||
1. **Lineage atlas — recommended companion view.** Project and worktree branches contain
|
||||
equal-size agent nodes, with child agents connected beneath the agent that started them.
|
||||
It makes ownership clear, but takes longer to scan for the newest reply.
|
||||
2. **Activity lanes — current-data prototype.** Worktrees become compact time lanes using
|
||||
thin start-to-end lines from the current Orca session inventory. Working sessions end at
|
||||
**Now**; completed sessions stop at completion. Sessions older than two hours are hidden
|
||||
until **Show older** is selected. Orchestrated children sit under their parent even when
|
||||
they run in another worktree; their own worktree remains in the row label.
|
||||
|
||||
Queue cells was removed because it duplicated the existing Dashboard instead of providing
|
||||
a distinct viewer.
|
||||
|
||||
## Current data
|
||||
|
||||
`actual-agent-activity.json` is a metadata-only snapshot captured from
|
||||
`orca worktree ps --json`. Session starts come from the first transcript metadata record,
|
||||
with persisted terminal creation time as a fallback. The capture does not copy terminal
|
||||
output, transcript messages, assistant responses, or file contents. Refresh both the JSON
|
||||
and browser-loadable snapshot with:
|
||||
|
||||
```sh
|
||||
node agent-viewer-alternatives/capture-agent-activity-snapshot.mjs
|
||||
```
|
||||
|
||||
Set `ORCA_AGENT_ACTIVITY_USER_DATA` when the relevant Orca profile data uses a custom data
|
||||
directory. The generated snapshot and actual-data screenshot are ignored by Git so local
|
||||
session labels are not committed accidentally.
|
||||
|
||||
## Shared interaction model
|
||||
|
||||
- Older completed sessions are hidden by default; **Show older** and **Hide older** are
|
||||
reversible.
|
||||
- Recent, status, project, worktree, and text filters work in every concept.
|
||||
- Selecting any agent makes room for the same dashboard-style terminal panel beside the
|
||||
current context.
|
||||
- Dragging an agent to **Talk to any agent** adds a recipient without sending. **Add to
|
||||
Talk** is the keyboard and non-drag fallback.
|
||||
- Working agents use the production yellow spinner/glow treatment; done agents use green.
|
||||
- Agent icons stay equal size within every concept.
|
||||
- Tabs, filters, agent buttons, recipient controls, theme switching, and the message form
|
||||
are keyboard accessible. `Cmd+K` on macOS and `Ctrl+K` elsewhere focuses search.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Keep the existing Dashboard as the operational default and add visible lineage there.
|
||||
Activity lanes is the history-oriented alternative; Lineage atlas remains the focused
|
||||
relationship view.
|
||||
|
||||
## Validation
|
||||
|
||||
- `node --check app.js`
|
||||
- Browser interaction pass from both the local HTTP URL and direct `file://` opening
|
||||
- Two concept tabs, light/dark switching, filters, show/hide older, side panel, keyboard
|
||||
search, drag recipient flow, non-drag fallback, and follow-up submission
|
||||
- Responsive checks at 1200px and 800px with no page-level horizontal overflow
|
||||
- Equal 36×36 agent icons and no browser console or page errors
|
||||