diff --git a/agent-dashboard-concepts/agent-dashboard-concepts.css b/agent-dashboard-concepts/agent-dashboard-concepts.css new file mode 100644 index 00000000000..7374eaf16ff --- /dev/null +++ b/agent-dashboard-concepts/agent-dashboard-concepts.css @@ -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; } +} diff --git a/agent-dashboard-concepts/agent-dashboard-concepts.html b/agent-dashboard-concepts/agent-dashboard-concepts.html new file mode 100644 index 00000000000..6439a257f0a --- /dev/null +++ b/agent-dashboard-concepts/agent-dashboard-concepts.html @@ -0,0 +1,256 @@ + + + + + + Orca Agent Dashboard Concepts + + + + + +
+
+ +
+

Agents

+ 12 across 4 projects +
+ +
+ + + +
+
+ +
+
+ +
6active
+
+ 3completed today +
+
1idle
+
+ +
+
+
+
+
Needs you2
+ +
+
+
+
+
+ Choose token refresh behavior + API Integrator · Orca / auth-refresh +
+ 1m + + +
+
+
!
+
+ Rate limit strategy exceeds quota + Backend Agent · Relay / usage-limits +
+ 6m + + +
+
+
+ +
+
+
Active missions3
+ +
+ +
+
+ +
Orca / Dashboard revampalbacore · 3 agents
+ + 68% +
+ + + +
+ + +
+
+ + +
+
+ +
+
+ +
+
+ 100% + +
+
+
Orca / Dashboard revampalbacore
+ + + + + + + + +
+
+ +
+
+ +
+
+
Agent history

What changed across your work

Compare outcomes, duration, and review state without reopening every terminal.

+
38 runs this week4h 12m active time81% completed
+
+
+
+ + +
+
+ + + + + + + + + +
AgentMissionProject / worktreeStartedDurationOutcomeOutput
CE2E TesterDashboard interaction flowsOrcaalbacore10:42 AM18mCompleted58 tests passed›
CAPI IntegratorMetrics endpointsOrcaalbacore10:26 AM31mCompletedPR #11342›
CBackend AgentConnection recoveryRelayrelay-reconnect9:51 AM42mInterrupted3 files changed›
CUI BuilderEmpty statesOrcadashboard-empty9:18 AM24mCompletedPR #11335›
CDocs AgentSSH troubleshooting guideDocsssh-guideYesterday16mCompleted2 docs updated›
+
+
+
+ + + diff --git a/agent-dashboard-concepts/agent-dashboard-concepts.js b/agent-dashboard-concepts/agent-dashboard-concepts.js new file mode 100644 index 00000000000..fc6d1a666c4 --- /dev/null +++ b/agent-dashboard-concepts/agent-dashboard-concepts.js @@ -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) + } +}) diff --git a/agent-dashboard-concepts/history.png b/agent-dashboard-concepts/history.png new file mode 100644 index 00000000000..0b090c1c8f8 Binary files /dev/null and b/agent-dashboard-concepts/history.png differ diff --git a/agent-dashboard-concepts/map.png b/agent-dashboard-concepts/map.png new file mode 100644 index 00000000000..06038e23049 Binary files /dev/null and b/agent-dashboard-concepts/map.png differ diff --git a/agent-dashboard-concepts/overview.png b/agent-dashboard-concepts/overview.png new file mode 100644 index 00000000000..1c2ff759c03 Binary files /dev/null and b/agent-dashboard-concepts/overview.png differ diff --git a/agent-fleet-concepts/README.md b/agent-fleet-concepts/README.md new file mode 100644 index 00000000000..18e79b0a464 --- /dev/null +++ b/agent-fleet-concepts/README.md @@ -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. diff --git a/agent-fleet-concepts/agent-fleet-concepts.css b/agent-fleet-concepts/agent-fleet-concepts.css new file mode 100644 index 00000000000..bb79813e781 --- /dev/null +++ b/agent-fleet-concepts/agent-fleet-concepts.css @@ -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; } +} diff --git a/agent-fleet-concepts/agent-fleet-concepts.html b/agent-fleet-concepts/agent-fleet-concepts.html new file mode 100644 index 00000000000..3e7c55d44d5 --- /dev/null +++ b/agent-fleet-concepts/agent-fleet-concepts.html @@ -0,0 +1,218 @@ + + + + + + Orca Agent Fleet Concepts + + + + + + +
+
+
O
+

Agents

Fleet command
+
168total
+ +
+ + +
+
+ +
+ +
+
All projects · Live topology
+ + +
+
pr-8783-internal-review13 agents · 14 terminals
3!
+
+
Review loopactive now
+
+
+
mobile-9717-dup-sessions12 agents · 13 terminals
2!
+
+
Mobile3m
+
+
+
pr-9190-internal-review12 agents · 13 terminals
+
+
Review loop6m
+
+
+
orca-trust-dialog5 agents · 7 terminals
1!
+
+
Desktop7m
+
+
+
codex-resume-argv-drop5 agents · 6 terminals
+
+
Runtime12m
+
+
42%
+
+
+ +
+ +
+ +
+
+
NEEDS YOU
+
ACTIVE
+
WATCH
+
RECENT
+
+ +
+ + + +
+ +
+ +
+
+
+
Fleet matrix

104 live worktrees

+
9needs you34working71unread332terminals
+
+
+
+
WorktreeAgent fabricNeeds youWorkingDoneTerminalsLast signal
+
+
+ +
+
+ +
+
+
+
Temporal command

Where time is accumulating

+
+
7 agentsactive longer than expected
+
+
-4h-3h-2h-1hNOW
+
+
+ +
+ +
+
+ +
+
+
+
⠿
ORCHOrchestratorglobal · no worktree
Fleet connected
+
+
❯show me what needs intervention across the fleet↵
+
+
9 agents need youGrouped into 4 decisions · 2 likely share the same cause
+
01
Runtime cleanup behaviorreview-11464 + pr-8783-internal-review

Three reviewers agree stale local rows should be dropped, but disagree on restart retention.

3 agents
+
02
Duplicate mobile sessionsmobile-9717-dup-sessions

Choose whether restored sessions replace or merge with live host state.

2 agents
+
03
Trust dialog permissionorca-trust-dialog

One test agent needs approval to continue accessibility validation.

1 agent
+
+
Try
+
+
⌘ ⇧ O summon⌘ J next alertEsc dismiss168 agents · 332 terminals · 3 hosts
+
+
All agents›review-11464
+
+ +
+
+
Mission treemap

Space equals agent count · edge glow equals urgency

Needs youWorkingQuiet
+
+
ORCA112 agents
6 need you
+ + + + + + +
+
MOBILE39 agents
2 need you
+ + + + +
+
RELAY + OTHER17 agents
1 need you
+ + +
+
+
Hover or click a region to zoomFleet coverage 100%
+
+
+
+ + + diff --git a/agent-fleet-concepts/agent-fleet-concepts.js b/agent-fleet-concepts/agent-fleet-concepts.js new file mode 100644 index 00000000000..3cc94575c2f --- /dev/null +++ b/agent-fleet-concepts/agent-fleet-concepts.js @@ -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) => ``).join('') + row.innerHTML = `${name}${total} agents${dots}${needs || '—'}${working}${done}${terminals}${signal}` + 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 `` + }).join('') + row.innerHTML = `${name}${total} agents${segments}${working} active` + timeline.append(row) +}) + +const initialView = window.location.hash.slice(1) +if (screens.some((screen) => screen.dataset.screen === initialView)) showView(initialView) diff --git a/agent-fleet-concepts/constellation.png b/agent-fleet-concepts/constellation.png new file mode 100644 index 00000000000..bb1cf723f29 Binary files /dev/null and b/agent-fleet-concepts/constellation.png differ diff --git a/agent-fleet-concepts/gallery.png b/agent-fleet-concepts/gallery.png new file mode 100644 index 00000000000..99edce9ddb5 Binary files /dev/null and b/agent-fleet-concepts/gallery.png differ diff --git a/agent-fleet-concepts/matrix.png b/agent-fleet-concepts/matrix.png new file mode 100644 index 00000000000..982cd64e072 Binary files /dev/null and b/agent-fleet-concepts/matrix.png differ diff --git a/agent-fleet-concepts/orchestrator.png b/agent-fleet-concepts/orchestrator.png new file mode 100644 index 00000000000..90c16aa7e6e Binary files /dev/null and b/agent-fleet-concepts/orchestrator.png differ diff --git a/agent-fleet-concepts/radar.png b/agent-fleet-concepts/radar.png new file mode 100644 index 00000000000..3a124531ed5 Binary files /dev/null and b/agent-fleet-concepts/radar.png differ diff --git a/agent-fleet-concepts/timeline.png b/agent-fleet-concepts/timeline.png new file mode 100644 index 00000000000..b17392419d3 Binary files /dev/null and b/agent-fleet-concepts/timeline.png differ diff --git a/agent-fleet-concepts/treemap.png b/agent-fleet-concepts/treemap.png new file mode 100644 index 00000000000..5e30e4c0ba8 Binary files /dev/null and b/agent-fleet-concepts/treemap.png differ diff --git a/agent-fleet-control-room/README.md b/agent-fleet-control-room/README.md new file mode 100644 index 00000000000..0b2a1fc3dcb --- /dev/null +++ b/agent-fleet-control-room/README.md @@ -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`. diff --git a/agent-fleet-control-room/capture-control-room.mjs b/agent-fleet-control-room/capture-control-room.mjs new file mode 100644 index 00000000000..549f3ef66a2 --- /dev/null +++ b/agent-fleet-control-room/capture-control-room.mjs @@ -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(); diff --git a/agent-fleet-control-room/capture-tiles-bands.mjs b/agent-fleet-control-room/capture-tiles-bands.mjs new file mode 100644 index 00000000000..e0f514cac1e --- /dev/null +++ b/agent-fleet-control-room/capture-tiles-bands.mjs @@ -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(); diff --git a/agent-fleet-control-room/control-room-tokens.css b/agent-fleet-control-room/control-room-tokens.css new file mode 100644 index 00000000000..ce5932531b8 --- /dev/null +++ b/agent-fleet-control-room/control-room-tokens.css @@ -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; } +} diff --git a/agent-fleet-control-room/fleet-board-compact.png b/agent-fleet-control-room/fleet-board-compact.png new file mode 100644 index 00000000000..daf944223e3 Binary files /dev/null and b/agent-fleet-control-room/fleet-board-compact.png differ diff --git a/agent-fleet-control-room/fleet-board-detail.png b/agent-fleet-control-room/fleet-board-detail.png new file mode 100644 index 00000000000..4fc5564b13c Binary files /dev/null and b/agent-fleet-control-room/fleet-board-detail.png differ diff --git a/agent-fleet-control-room/fleet-board.css b/agent-fleet-control-room/fleet-board.css new file mode 100644 index 00000000000..37bfed6f975 --- /dev/null +++ b/agent-fleet-control-room/fleet-board.css @@ -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%; +} diff --git a/agent-fleet-control-room/fleet-board.html b/agent-fleet-control-room/fleet-board.html new file mode 100644 index 00000000000..7829ac03236 --- /dev/null +++ b/agent-fleet-control-room/fleet-board.html @@ -0,0 +1,39 @@ + + + + + + Fleet — Board + + + + +
+ Fleet + +
+
+ + + +
+ + +
+ +
+ + + + + + + + + + diff --git a/agent-fleet-control-room/fleet-board.js b/agent-fleet-control-room/fleet-board.js new file mode 100644 index 00000000000..b7a33bc02ab --- /dev/null +++ b/agent-fleet-control-room/fleet-board.js @@ -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); +})(); diff --git a/agent-fleet-control-room/fleet-board.png b/agent-fleet-control-room/fleet-board.png new file mode 100644 index 00000000000..bf8355e75d7 Binary files /dev/null and b/agent-fleet-control-room/fleet-board.png differ diff --git a/agent-fleet-control-room/fleet-canvas.css b/agent-fleet-control-room/fleet-canvas.css new file mode 100644 index 00000000000..60dfdda11cd --- /dev/null +++ b/agent-fleet-control-room/fleet-canvas.css @@ -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); +} diff --git a/agent-fleet-control-room/fleet-canvas.html b/agent-fleet-control-room/fleet-canvas.html new file mode 100644 index 00000000000..cc3ea40a10b --- /dev/null +++ b/agent-fleet-control-room/fleet-canvas.html @@ -0,0 +1,71 @@ + + + + + + Orca Fleet — Canvas + + + + + +
+
+ Fleet + +
+
+ + +
+
+ +
+ + + + + + + + + + + + + + + + +
+ + +
+
+ + +
+ + + + + + + + + diff --git a/agent-fleet-control-room/fleet-canvas.js b/agent-fleet-control-room/fleet-canvas.js new file mode 100644 index 00000000000..e98b8aa600f --- /dev/null +++ b/agent-fleet-control-room/fleet-canvas.js @@ -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) => + '' + + '' + x.n + '' + + '' + x.lbl + '', + ) + .join('·'); + + 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) => '' + k[1] + '') + .join('·') + + '·scroll to zoom · drag to pan'; + } + + // ---- 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 = + '
' + esc(a.shortName) + '
' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '
' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '
' + + 'started ' + F.ageLabel(a.startedAgo) + ' ago · ran ' + F.ageLabel(a.durationMs) + + '
' + esc(a.detail || '—') + '
'; + 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 += + '' + + ''; + } + 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) => '' + (esc(l) || ' ') + '') + .join(''); + } + + function respondHtml(a) { + if (a.state === 'attention') { + const opts = a.question.options + .map((o, i) => '') + .join(''); + return ( + '

' + esc(a.question.text) + '

' + opts + '
' + + '
' + + '
' + ); + } + if (a.state === 'permission') { + const d = F.decisions.find((x) => x.key === a.decisionKey); + return ( + '

$ ' + esc(d ? d.command : '') + '

' + + '
' + + '' + + '
' + ); + } + if (a.state === 'failed') { + return ( + '

' + esc(a.failure) + '

' + + '
' + + '
' + ); + } + if (a.state === 'review') { + return ( + '
' + + '
' + ); + } + if (a.state === 'working') { + return ( + '
' + + '
' + ); + } + return '
'; + } + + 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 = + '' + + '
' + + '

' + esc(a.shortName) + '

' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '

' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '

' + + '
' + + '
Started
' + clock(a.startedAt) + '
' + + '
Elapsed
' + F.ageLabel(a.startedAgo) + '
' + + '
Duration
' + F.ageLabel(a.durationMs) + '
' + + '
' + + '
' + tailHtml(a) + '
' + + '
' + + '
' + respondHtml(a) + '
'; + 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; +})(); diff --git a/agent-fleet-control-room/fleet-canvas.png b/agent-fleet-control-room/fleet-canvas.png new file mode 100644 index 00000000000..2cf504fd80b Binary files /dev/null and b/agent-fleet-control-room/fleet-canvas.png differ diff --git a/agent-fleet-control-room/fleet-glass.css b/agent-fleet-control-room/fleet-glass.css new file mode 100644 index 00000000000..1294ac76849 --- /dev/null +++ b/agent-fleet-control-room/fleet-glass.css @@ -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%; +} diff --git a/agent-fleet-control-room/fleet-glass.html b/agent-fleet-control-room/fleet-glass.html new file mode 100644 index 00000000000..60b6bf95acd --- /dev/null +++ b/agent-fleet-control-room/fleet-glass.html @@ -0,0 +1,36 @@ + + + + + + Fleet — Glass + + + + +
+ Fleet + + + +
+ +
+ +
+ + + + +
+ + + + + + diff --git a/agent-fleet-control-room/fleet-glass.js b/agent-fleet-control-room/fleet-glass.js new file mode 100644 index 00000000000..81756834c71 --- /dev/null +++ b/agent-fleet-control-room/fleet-glass.js @@ -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)); +})(); diff --git a/agent-fleet-control-room/fleet-glass.png b/agent-fleet-control-room/fleet-glass.png new file mode 100644 index 00000000000..57d2b623dfc Binary files /dev/null and b/agent-fleet-control-room/fleet-glass.png differ diff --git a/agent-fleet-control-room/fleet-hexmap.css b/agent-fleet-control-room/fleet-hexmap.css new file mode 100644 index 00000000000..48a645197e3 --- /dev/null +++ b/agent-fleet-control-room/fleet-hexmap.css @@ -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); +} diff --git a/agent-fleet-control-room/fleet-hexmap.html b/agent-fleet-control-room/fleet-hexmap.html new file mode 100644 index 00000000000..95f519ecde2 --- /dev/null +++ b/agent-fleet-control-room/fleet-hexmap.html @@ -0,0 +1,51 @@ + + + + + + Orca Fleet — Hexmap + + + + + +
+
+ Fleet + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + diff --git a/agent-fleet-control-room/fleet-hexmap.js b/agent-fleet-control-room/fleet-hexmap.js new file mode 100644 index 00000000000..206c1569094 --- /dev/null +++ b/agent-fleet-control-room/fleet-hexmap.js @@ -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) => + '' + + x.n + + '' + + x.lbl + + '', + ) + .join('·'); + + 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) => '' + k[1] + '') + .join('·') + '·still = settled'; + } + + // ---- field ----------------------------------------------------------------- + + function markup(a) { + if (a.state === 'done') return ''; + if (a.state === 'working') + return a.loopSleeping ? '' : ''; + return '' + F.stateGlyph(a.state) + ''; + } + + 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 = + '' + + '' + markup(a) + ''; + 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 = + '
' + esc(a.shortName) + '
' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '
' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '
' + + 'started ' + F.ageLabel(a.startedAgo) + ' ago · ran ' + F.ageLabel(a.durationMs) + + '
' + esc(a.detail || '—') + '
'; + 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 += + '' + + ''; + } + 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) => '' + (esc(l) || ' ') + '') + .join(''); + } + + function respondHtml(a) { + if (a.state === 'attention') { + const opts = a.question.options + .map((o, i) => '') + .join(''); + return ( + '

' + esc(a.question.text) + '

' + opts + '
' + + '
' + + '
' + ); + } + if (a.state === 'permission') { + const d = F.decisions.find((x) => x.key === a.decisionKey); + return ( + '

$ ' + esc(d ? d.command : '') + '

' + + '
' + + '' + + '
' + ); + } + if (a.state === 'failed') { + return ( + '

' + esc(a.failure) + '

' + + '
' + + '
' + ); + } + if (a.state === 'review') { + return ( + '
' + + '
' + ); + } + if (a.state === 'working') { + return ( + '
' + + '
' + ); + } + return '
'; + } + + 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 = + '' + + '
' + + '

' + esc(a.shortName) + '

' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '

' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '

' + + '
' + + '
Started
' + clock(a.startedAt) + '
' + + '
Elapsed
' + F.ageLabel(a.startedAgo) + '
' + + '
Duration
' + F.ageLabel(a.durationMs) + '
' + + '
' + + '
' + tailHtml(a) + '
' + + '
' + + '
' + respondHtml(a) + '
'; + 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)); +})(); diff --git a/agent-fleet-control-room/fleet-hexmap.png b/agent-fleet-control-room/fleet-hexmap.png new file mode 100644 index 00000000000..ac0cf193afd Binary files /dev/null and b/agent-fleet-control-room/fleet-hexmap.png differ diff --git a/agent-fleet-control-room/fleet-inbox.css b/agent-fleet-control-room/fleet-inbox.css new file mode 100644 index 00000000000..006f2eaa4ff --- /dev/null +++ b/agent-fleet-control-room/fleet-inbox.css @@ -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; +} diff --git a/agent-fleet-control-room/fleet-inbox.html b/agent-fleet-control-room/fleet-inbox.html new file mode 100644 index 00000000000..9c8cbadab81 --- /dev/null +++ b/agent-fleet-control-room/fleet-inbox.html @@ -0,0 +1,37 @@ + + + + + + Fleet — Inbox + + + + + + +
+
+

+

+
+ +
+ +
+ +
+
+ + + + + + diff --git a/agent-fleet-control-room/fleet-inbox.js b/agent-fleet-control-room/fleet-inbox.js new file mode 100644 index 00000000000..7df256b219b --- /dev/null +++ b/agent-fleet-control-room/fleet-inbox.js @@ -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'; +})(); diff --git a/agent-fleet-control-room/fleet-inbox.png b/agent-fleet-control-room/fleet-inbox.png new file mode 100644 index 00000000000..6a2e5ba338e Binary files /dev/null and b/agent-fleet-control-room/fleet-inbox.png differ diff --git a/agent-fleet-control-room/fleet-map.css b/agent-fleet-control-room/fleet-map.css new file mode 100644 index 00000000000..04184f852ec --- /dev/null +++ b/agent-fleet-control-room/fleet-map.css @@ -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; +} diff --git a/agent-fleet-control-room/fleet-map.html b/agent-fleet-control-room/fleet-map.html new file mode 100644 index 00000000000..e9292266c6b --- /dev/null +++ b/agent-fleet-control-room/fleet-map.html @@ -0,0 +1,52 @@ + + + + + + Orca Fleet — Containment Map + + + + + + + +
+
+

Fleet

+ + + +

+
+ +
+ +
+
+
+ +
+
+ + +
+
+ + + + + + + + diff --git a/agent-fleet-control-room/fleet-map.js b/agent-fleet-control-room/fleet-map.js new file mode 100644 index 00000000000..a7fb916c408 --- /dev/null +++ b/agent-fleet-control-room/fleet-map.js @@ -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 ''; + if (a.state === 'working') return ''; + if (a.state === 'done') return ''; + return '' + esc(F.stateGlyph(a.state)) + ''; + } + + 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) + '' + ); + } + + // 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 ''; + } + + // ---- top bar + dials ------------------------------------------------------- + + function renderChrome() { + el.searchKbd.textContent = MOD + 'K'; + el.accounting.innerHTML = + '' + F.counts.total + ' of ' + F.counts.fleetTotal + ' agents · ' + + '' + F.byWorktree.length + ' 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) => + '
' + + '' + d.n + '' + + (d.delta && d.n > 0 ? '↑' : '') + + '' + + '' + esc(d.label) + '' + + '
' + ) + .join(''); + } + + // ---- bands ----------------------------------------------------------------- + + function renderBands() { + el.bands.innerHTML = F.projects + .map((p) => { + const cells = p.worktrees + .map( + (g) => + '
' + + '
' + + '' + esc(g.worktree) + '' + + (g.unread ? '' : '') + + '' + g.agents.length + '' + + '
' + + '
' + g.agents.map((a) => glyphHtml(a, true)).join('') + '
' + + '
' + ) + .join(''); + return ( + '
' + + '
' + + '' + esc(p.name) + '' + + '' + p.worktrees.length + ' worktrees · ' + p.agents.length + ' agents' + + '
' + + '
' + (cells || '

No worktrees in this project.

') + '
' + + '
' + ); + }) + .join(''); + } + + function renderLegend() { + const states = ['attention', 'permission', 'failed', 'working', 'review', 'done']; + el.legend.innerHTML = + states + .map((s) => '' + stateChip(s) + esc(STATE_LABEL[s]) + '') + .join('') + + 'fast blink = needs you · slow pulse = awaiting review · still = settled'; + } + + // ---- tooltip --------------------------------------------------------------- + + function showTooltip(node, a) { + el.tooltip.innerHTML = + '

' + esc(a.shortName) + '

' + + '

' + stateChip(a.state) + esc(STATE_LABEL[a.state]) + ' · ' + esc(livenessLabel(a)) + '

' + + '
' + + '
Runner
' + esc(a.agentType) + '
' + + '
Started
' + esc(F.ageLabel(a.startedAgo)) + ' ago
' + + '
Duration
' + esc(F.ageLabel(a.durationMs)) + '
' + + '
Doing
' + esc(a.detail || '—') + '
' + + '
'; + 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 ( + 'Respond in place' + + '

' + esc(a.question.text) + '

' + + '
' + + a.question.options + .map((o, i) => '') + .join('') + + '
' + + '
' + + '
' + ); + } + if (a.state === 'permission') { + const d = F.decisions.find((x) => x.key === a.decisionKey); + return ( + 'Permission requested' + + (d ? '' + esc(d.command) + '

' + esc(d.detail) + '

' : '') + + '
' + + '' + + '
' + ); + } + if (a.state === 'failed') { + return ( + 'Failure' + + '

' + esc(a.failure || 'Process exited unexpectedly.') + '

' + + '
' + + '
' + ); + } + if (a.state === 'review') { + return ( + 'Finished · awaiting review' + + (a.pr ? 'PR #' + a.pr.number + ' · ' + esc(a.pr.state) + '' : '') + + '
' + + '' + + '
' + ); + } + if (a.state === 'working') { + return ( + 'In flight' + + '
' + + '
' + ); + } + return ( + 'Settled' + + (a.pr ? 'PR #' + a.pr.number + ' · ' + esc(a.pr.state) + '' : '') + + '
' + ); + } + + const TAIL_LINES = 9; + + function renderDetail() { + if (!selectedId) { + el.detail.innerHTML = + '

Select an agentclick any glyph

'; + 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 ? '' + esc(line) + '' : esc(line) + ).join('\n'); + + el.detail.innerHTML = + 'Selected agent' + + '

' + esc(a.name) + '

' + + '

' + stateChip(a.state) + esc(STATE_LABEL[a.state]) + + '· ' + esc(livenessLabel(a)) + '

' + + '

' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '

' + + '
' + + '
Started' + clockLabel(a.startedAt) + '
' + + '
Elapsed' + esc(F.ageLabel(a.startedAgo)) + '
' + + '
Duration' + esc(F.ageLabel(a.durationMs)) + '
' + + '
Runner' + esc(a.agentType) + '
' + + '
' + + '
' + tailHtml + '
' + + '
' + respondHtml(a) + '
'; + } + + // ---- 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 += + '' + + ''; + } + 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)); +})(); diff --git a/agent-fleet-control-room/fleet-map.png b/agent-fleet-control-room/fleet-map.png new file mode 100644 index 00000000000..3fb2e0261cb Binary files /dev/null and b/agent-fleet-control-room/fleet-map.png differ diff --git a/agent-fleet-control-room/fleet-space.css b/agent-fleet-control-room/fleet-space.css new file mode 100644 index 00000000000..dddb44729aa --- /dev/null +++ b/agent-fleet-control-room/fleet-space.css @@ -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; +} diff --git a/agent-fleet-control-room/fleet-space.html b/agent-fleet-control-room/fleet-space.html new file mode 100644 index 00000000000..20fa5182e58 --- /dev/null +++ b/agent-fleet-control-room/fleet-space.html @@ -0,0 +1,44 @@ + + + + + + Fleet — Space + + + + + +
+ Fleet + + + +
+ +
+ +
+
+
+ +
+ + +
+ + + + +
+ + + + + + diff --git a/agent-fleet-control-room/fleet-space.js b/agent-fleet-control-room/fleet-space.js new file mode 100644 index 00000000000..eed27e1082f --- /dev/null +++ b/agent-fleet-control-room/fleet-space.js @@ -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)); + }); +})(); diff --git a/agent-fleet-control-room/fleet-space.png b/agent-fleet-control-room/fleet-space.png new file mode 100644 index 00000000000..4156e530e0d Binary files /dev/null and b/agent-fleet-control-room/fleet-space.png differ diff --git a/agent-fleet-control-room/fleet-state-synthesis.js b/agent-fleet-control-room/fleet-state-synthesis.js new file mode 100644 index 00000000000..c39a10263c6 --- /dev/null +++ b/agent-fleet-control-room/fleet-state-synthesis.js @@ -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), + }; +})(); diff --git a/agent-fleet-control-room/fleet-strips.css b/agent-fleet-control-room/fleet-strips.css new file mode 100644 index 00000000000..7a4724eda88 --- /dev/null +++ b/agent-fleet-control-room/fleet-strips.css @@ -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); +} diff --git a/agent-fleet-control-room/fleet-strips.html b/agent-fleet-control-room/fleet-strips.html new file mode 100644 index 00000000000..b527b9fbb09 --- /dev/null +++ b/agent-fleet-control-room/fleet-strips.html @@ -0,0 +1,56 @@ + + + + + + Fleet · Strips + + + + +
+
+
+

Fleet

+ +

+
+ +
+
+ Group +
+ + +
+
+ +
+
+ +
+ +
+
+
+ + fast blink = needs you + · + + slow pulse = awaiting review + · + + still = settled +
+
+
+ + + + + + diff --git a/agent-fleet-control-room/fleet-strips.js b/agent-fleet-control-room/fleet-strips.js new file mode 100644 index 00000000000..b88584846eb --- /dev/null +++ b/agent-fleet-control-room/fleet-strips.js @@ -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(); +})(); diff --git a/agent-fleet-control-room/fleet-strips.png b/agent-fleet-control-room/fleet-strips.png new file mode 100644 index 00000000000..300945322da Binary files /dev/null and b/agent-fleet-control-room/fleet-strips.png differ diff --git a/agent-fleet-control-room/fleet-terminal-wall.css b/agent-fleet-control-room/fleet-terminal-wall.css new file mode 100644 index 00000000000..52af37933b3 --- /dev/null +++ b/agent-fleet-control-room/fleet-terminal-wall.css @@ -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); +} diff --git a/agent-fleet-control-room/fleet-terminal-wall.html b/agent-fleet-control-room/fleet-terminal-wall.html new file mode 100644 index 00000000000..3a9c8f21b64 --- /dev/null +++ b/agent-fleet-control-room/fleet-terminal-wall.html @@ -0,0 +1,48 @@ + + + + + + Orca Fleet — Wall + + + + + +
+
+ Fleet + +
+
+ + +
+
+ +
+ +
+
+ + + + + + + + + diff --git a/agent-fleet-control-room/fleet-terminal-wall.js b/agent-fleet-control-room/fleet-terminal-wall.js new file mode 100644 index 00000000000..2dbce85677c --- /dev/null +++ b/agent-fleet-control-room/fleet-terminal-wall.js @@ -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) => + '' + + '' + x.n + '' + + '' + x.lbl + '', + ) + .join('·'); + + 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) => '' + x[1] + '') + .join('·') + + '·motion = output'; + } + + // ---- 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 ( + '
' + (esc(text) || ' ') + '' + + (st.cursor ? '▍' : '') + '
' + ); + } + + function termHtml(st) { + return st.lines + .map((l, i) => + i === st.lines.length - 1 ? lastLineHtml(st, l) : '
' + esc(l) + '
', + ) + .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 ''; + if (a.state === 'working') return ''; + if (a.state === 'review') return '✔'; + 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 ? '#' : ''; + } else { + const st = makeStream(a); + node.innerHTML = + markHtml(a) + + '
' + termHtml(st, false) + '
' + + '
' + esc(a.shortName) + '
'; + 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 = + '
' + esc(a.shortName) + '
' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '
' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '
' + + 'started ' + F.ageLabel(a.startedAgo) + ' ago · ran ' + F.ageLabel(a.durationMs) + + '
' + esc(a.detail || '—') + '
'; + 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) : '
' + (esc(l) || ' ') + '
')) + .join(''); + } + + function respondHtml(a) { + if (a.state === 'attention') { + const opts = a.question.options + .map((o, i) => '') + .join(''); + return ( + '

' + esc(a.question.text) + '

' + opts + '
' + + '
' + + '
' + ); + } + if (a.state === 'permission') { + const d = F.decisions.find((x) => x.key === a.decisionKey); + return ( + '

$ ' + esc(d ? d.command : '') + '

' + + '
' + + '' + + '
' + ); + } + if (a.state === 'failed') { + return ( + '

' + esc(a.failure) + '

' + + '
' + + '
' + ); + } + if (a.state === 'review') { + return ( + '
' + + '
' + ); + } + if (a.state === 'working') { + return ( + '
' + + '
' + ); + } + return '
'; + } + + 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 = + '' + + '
' + + '

' + esc(a.shortName) + '

' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '

' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '

' + + '
' + + '
Started
' + clock(a.startedAt) + '
' + + '
Elapsed
' + F.ageLabel(a.startedAgo) + '
' + + '
Duration
' + F.ageLabel(a.durationMs) + '
' + + '
' + + '
Tool
' + esc(a.detail || '—') + '
' + + '
' + + '
' + + '
' + respondHtml(a) + '
'; + + 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); +})(); diff --git a/agent-fleet-control-room/fleet-terminal-wall.png b/agent-fleet-control-room/fleet-terminal-wall.png new file mode 100644 index 00000000000..6a0163c54fa Binary files /dev/null and b/agent-fleet-control-room/fleet-terminal-wall.png differ diff --git a/agent-fleet-control-room/fleet-tiles-far.png b/agent-fleet-control-room/fleet-tiles-far.png new file mode 100644 index 00000000000..d22b2029377 Binary files /dev/null and b/agent-fleet-control-room/fleet-tiles-far.png differ diff --git a/agent-fleet-control-room/fleet-tiles-near.png b/agent-fleet-control-room/fleet-tiles-near.png new file mode 100644 index 00000000000..41ca2687b19 Binary files /dev/null and b/agent-fleet-control-room/fleet-tiles-near.png differ diff --git a/agent-fleet-control-room/fleet-tiles.css b/agent-fleet-control-room/fleet-tiles.css new file mode 100644 index 00000000000..da669a73ff2 --- /dev/null +++ b/agent-fleet-control-room/fleet-tiles.css @@ -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); +} diff --git a/agent-fleet-control-room/fleet-tiles.html b/agent-fleet-control-room/fleet-tiles.html new file mode 100644 index 00000000000..c82f3a8ef2b --- /dev/null +++ b/agent-fleet-control-room/fleet-tiles.html @@ -0,0 +1,71 @@ + + + + + + Orca Fleet — Tiles + + + + + +
+
+ Fleet + +
+
+ + +
+
+ +
+
+ + +
+ + + +
+ + +
+ +
+ +
+
+ +
+
+ + + + + + + + + diff --git a/agent-fleet-control-room/fleet-tiles.js b/agent-fleet-control-room/fleet-tiles.js new file mode 100644 index 00000000000..d7131a97678 --- /dev/null +++ b/agent-fleet-control-room/fleet-tiles.js @@ -0,0 +1,1165 @@ +/* Fleet Tiles — one semantic-zoom canvas for the whole fleet. + Every agent is the same rounded tile in world units; altitude decides what is + INSIDE it: a colour chip far out, a card at cruising height, a live terminal up + close. Repos are islands you can rearrange; agents are not draggable, because + the furniture is yours and the people are not. + Placement is a pure function of the fleet data, so the map is byte-identical on + every load. Ages come from FLEET.now; timers pace typing 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; + + // ---- world geometry (world units == CSS px before #world's transform) ------ + + const TILE_W = 132; + const TILE_H = 80; + const TILE_GAP = 10; + const GROUP_GAP = 16; + const ROW_GAP = 16; + const LABEL_H = 20; + const PAD_X = 26; + const PAD_TOP = 64; // island header lane + const PAD_BOTTOM = 26; + const ISLAND_HGAP = 90; + const ISLAND_VGAP = 50; + // Shelf widths chosen so the three islands fit-all at ~0.62 on a 1600×1000 + // window — inside the mid band, where the cards are the readable layer. + const ISLAND_MAX_W = { orca: 1590, 'orca-mobile': 570, automation: 720 }; + const ORCA_CENTRE = { x: 1700, y: 1100 }; + + // ---- viewport -------------------------------------------------------------- + + const MIN_SCALE = 0.25; + const MAX_SCALE = 3; + const BAND_FAR = 0.55; // below: colour chips only + const BAND_NEAR = 1.4; // at or above: live terminals + const FIT_PAD = 40; + // Fit never drops the window into the far band, where the map goes wordless. + const FIT_FLOOR = 0.58; + const DRAG_THRESHOLD_PX = 3; + const FOCUS_SCALE = 1.5; + + const TERM_LINES = 5; + const PANEL_LINES = 8; + const MAX_STREAM = 14; + const LAYOUT_KEY = 'fleet-tiles-layout-v1'; + + const STATE_LABEL = { + attention: 'Waiting on your answer', + permission: 'Waiting on permission', + failed: 'Failed', + working: 'Working', + review: 'Ready to review', + done: 'Done', + }; + // codex and claude both start with c, so the chip carries two letters. + const TYPE_CHIP = { codex: 'cx', claude: 'cl', grok: 'gk' }; + + const el = { + stage: document.getElementById('stage'), + world: document.getElementById('world'), + lineage: document.getElementById('lineage'), + 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'), + reset: document.getElementById('resetlayout'), + minimap: document.getElementById('minimap'), + mmsvg: document.getElementById('mmsvg'), + mmislands: document.getElementById('mmislands'), + mmdots: document.getElementById('mmdots'), + mmview: document.getElementById('mmview'), + }; + + 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 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); + } + } + + // ---- persisted furniture deltas ------------------------------------------- + + function loadLayout() { + try { + return JSON.parse(window.localStorage.getItem(LAYOUT_KEY) || '{}') || {}; + } catch (err) { + return {}; + } + } + function saveLayout() { + try { + window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(deltas)); + } catch (err) { + /* private mode / file:// with storage disabled — the layout is just not sticky */ + } + } + const deltas = loadLayout(); + const ZERO = { dx: 0, dy: 0 }; + const deltaOf = (key) => deltas[key] || ZERO; + + // ---- layout ---------------------------------------------------------------- + + function groupSize(n) { + const cols = Math.min(2, n); + const rows = Math.ceil(n / cols); + return { + cols, + rows, + w: cols * TILE_W + (cols - 1) * TILE_GAP, + h: rows * TILE_H + (rows - 1) * TILE_GAP + LABEL_H, + }; + } + + const islands = []; + const groups = []; + const tiles = []; + const islandByName = new Map(); + const groupByKey = new Map(); + const tileOf = new Map(); + + for (const p of F.projects) { + const maxW = ISLAND_MAX_W[p.name] || 860; + // Tall groups lead so the shelf rows pack without ragged vertical slack; ties + // keep FLEET.byWorktree order, which is alphabetical and therefore stable. + const order = p.worktrees + .map((g, i) => [g, i]) + .sort( + (a, b) => groupSize(b[0].agents.length).h - groupSize(a[0].agents.length).h || a[1] - b[1], + ) + .map((x) => x[0]); + + const isl = { name: p.name, project: p, worstState: p.worstState, groups: [] }; + let x = 0; + let y = 0; + let rowH = 0; + let contentW = 0; + + for (const wt of order) { + const s = groupSize(wt.agents.length); + if (x > 0 && x + s.w > maxW) { + x = 0; + y += rowH + ROW_GAP; + rowH = 0; + } + const g = { + key: p.name + '/' + wt.worktree, + wt, + island: isl, + baseX: x, + baseY: y, + w: s.w, + h: s.h, + tiles: [], + }; + wt.agents.forEach((a, i) => { + const t = { + a, + group: g, + dx: (i % s.cols) * (TILE_W + TILE_GAP), + dy: LABEL_H + Math.floor(i / s.cols) * (TILE_H + TILE_GAP), + }; + g.tiles.push(t); + tiles.push(t); + tileOf.set(a.id, t); + }); + isl.groups.push(g); + groups.push(g); + groupByKey.set(g.key, g); + x += s.w + GROUP_GAP; + contentW = Math.max(contentW, x - GROUP_GAP); + rowH = Math.max(rowH, s.h); + } + + // A label may overhang its own group — a one-agent group is narrower than its + // worktree name — but never far enough to collide with the next label in the + // row, so it ellipsises at the neighbour instead of overprinting it. + isl.groups.forEach((g, i) => { + const next = isl.groups[i + 1]; + const edge = next && next.baseY === g.baseY ? next.baseX : contentW + GROUP_GAP; + g.labelW = edge - g.baseX - 6; + }); + + isl.w = contentW + 2 * PAD_X; + isl.h = y + rowH + PAD_TOP + PAD_BOTTOM; + islands.push(isl); + islandByName.set(isl.name, isl); + } + + // orca is the anchor; mobile takes the right flank, automation the upper-left. + (function placeIslands() { + const anchor = + islandByName.get('orca') || islands.slice().sort((a, b) => b.w * b.h - a.w * a.h)[0]; + if (!anchor) return; + anchor.baseX = ORCA_CENTRE.x - anchor.w / 2; + anchor.baseY = ORCA_CENTRE.y - anchor.h / 2; + const up = islandByName.get('automation'); + if (up && up !== anchor) { + up.baseX = anchor.baseX; + up.baseY = anchor.baseY - ISLAND_VGAP - up.h; + } + const right = islandByName.get('orca-mobile'); + if (right && right !== anchor) { + right.baseX = anchor.baseX + anchor.w + ISLAND_HGAP; + right.baseY = anchor.baseY + (anchor.h - right.h) / 2; + } + let spill = anchor.baseX + anchor.w + ISLAND_HGAP; + for (const i of islands) { + if (typeof i.baseX === 'number') continue; + i.baseX = spill; + i.baseY = anchor.baseY; + spill += i.w + ISLAND_HGAP; + } + })(); + + const islandPos = (i) => { + const d = deltaOf('island:' + i.name); + return { x: i.baseX + d.dx, y: i.baseY + d.dy }; + }; + const groupOffset = (g) => { + const d = deltaOf('wt:' + g.key); + return { x: PAD_X + g.baseX + d.dx, y: PAD_TOP + g.baseY + d.dy }; + }; + const groupPos = (g) => { + const ip = islandPos(g.island); + const go = groupOffset(g); + return { x: ip.x + go.x, y: ip.y + go.y }; + }; + const tileCentre = (t) => { + const gp = groupPos(t.group); + return { x: gp.x + t.dx + TILE_W / 2, y: gp.y + t.dy + TILE_H / 2 }; + }; + + function worldBounds() { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const grow = (x, y, w, h) => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + }; + for (const i of islands) { + const p = islandPos(i); + grow(p.x, p.y, i.w, i.h); + } + // A group dragged out of formation still belongs to the fleet's extent. + for (const g of groups) { + const p = groupPos(g); + grow(p.x, p.y, g.w, g.h); + } + return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY }; + } + + // ---- output streams (deterministic content, jittered pacing) --------------- + + 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 streams = new Map(); + const fill = (line, a) => + line.replace('{detail}', a.detail || 'thinking').replace('{wt}', a.worktree); + + 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_STREAM) st.lines = st.lines.slice(-MAX_STREAM); + } + + function streamOf(id) { + if (streams.has(id)) return streams.get(id); + const a = byId.get(id); + const h = F.hash(a.id); + const st = { + agent: a, + h, + i: 0, + stride: 1 + (h % 5), + lines: F.fakeTail(a).slice(), + cursor: a.state === 'working' && !a.loopSleeping, + }; + if (a.loopSleeping) st.lines = st.lines.concat(['', '☾ sleeping · next run 4m']); + // Stagger the starting depth so the first near-band frame already looks mid-work. + else if (a.state === 'working') st.lines = st.lines.concat(pull(st, 1 + (h % 8))); + trim(st); + streams.set(id, st); + return st; + } + + // The newest line ellipsizes rather than hard-clipping, so the cursor — the whole + // "this one is alive" signal — never falls off the right edge of a small tile. + function lastLineHtml(st, text) { + return ( + '
' + + (esc(text) || ' ') + + '' + + (st.cursor ? '▍' : '') + + '
' + ); + } + + function termHtml(st, n) { + const shown = st.lines.slice(-n); + return shown + .map((l, i) => + i === shown.length - 1 ? lastLineHtml(st, l) : '
' + (esc(l) || ' ') + '
', + ) + .join(''); + } + + // ---- 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) => + '' + + x.n + + '' + + x.lbl + + '', + ) + .join('·'); + + 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) => '' + k[1] + '') + .join('·') + + '·scroll to zoom · drag headers to arrange · ' + + 'F next'; + } + + // ---- world ----------------------------------------------------------------- + + function glyphFor(a) { + if (a.loopSleeping) return '☾'; + return F.stateGlyph(a.state); + } + + function buildTile(t) { + const a = t.a; + const n = document.createElement('div'); + n.className = 'tile' + (a.alive ? '' : ' is-dead'); + n.dataset.agent = a.id; + n.dataset.state = a.state; + n.dataset.bucket = a.bucket; + n.style.left = t.dx + 'px'; + n.style.top = t.dy + 'px'; + n.tabIndex = 0; + n.setAttribute('role', 'button'); + n.setAttribute( + 'aria-label', + a.shortName + ' — ' + STATE_LABEL[a.state] + ', ' + a.displayProject + '/' + a.worktree, + ); + n.innerHTML = + '
' + + glyphFor(a) + + '
' + + esc(a.shortName) + + '
' + + (TYPE_CHIP[a.agentType] || a.agentType.slice(0, 2)) + + '' + + F.ageLabel(a.startedAgo) + + '' + + (a.pr ? '#' + a.pr.number + '' : '') + + '
'; + t.node = n; + return n; + } + + function positionIsland(i) { + const p = islandPos(i); + i.node.style.left = p.x + 'px'; + i.node.style.top = p.y + 'px'; + } + + function positionGroup(g) { + const o = groupOffset(g); + g.node.style.left = o.x + 'px'; + g.node.style.top = o.y + 'px'; + } + + function buildWorld() { + for (const i of islands) { + const n = document.createElement('div'); + n.className = 'island'; + n.dataset.island = i.name; + n.dataset.worst = i.worstState; + n.style.width = i.w + 'px'; + n.style.height = i.h + 'px'; + n.innerHTML = + '
' + + '
' + + esc(i.name) + + '
' + + i.project.worktrees.length + + ' worktrees · ' + + i.project.agents.length + + ' agents
'; + i.node = n; + positionIsland(i); + + for (const g of i.groups) { + const gn = document.createElement('div'); + gn.className = 'group' + (g.wt.worstState === 'done' ? ' is-quiet' : ''); + gn.dataset.group = g.key; + gn.style.width = g.w + 'px'; + gn.style.height = g.h + 'px'; + gn.innerHTML = + '
' + + (g.wt.unread ? '' : '') + + '' + + esc(g.wt.worktree) + + '
'; + g.node = gn; + positionGroup(g); + for (const t of g.tiles) gn.appendChild(buildTile(t)); + n.appendChild(gn); + } + el.world.appendChild(n); + } + } + + // ---- transform ------------------------------------------------------------- + + let tf = { x: 0, y: 0, scale: 1 }; + let band = ''; + let anim = null; + + // Cached: every pan frame writes the transform and then needs the stage size, and + // reading the rect back in the same frame would force a reflow per mousemove. + let rectCache = null; + const stageRect = () => { + if (!rectCache) rectCache = el.stage.getBoundingClientRect(); + return rectCache; + }; + + function applyTransform() { + el.world.style.transform = + 'translate(' + tf.x.toFixed(2) + 'px,' + tf.y.toFixed(2) + 'px) scale(' + tf.scale.toFixed(4) + ')'; + const next = tf.scale < BAND_FAR ? 'far' : tf.scale < BAND_NEAR ? 'mid' : 'near'; + if (next !== band) { + band = next; + el.stage.dataset.zoom = next; + if (band === 'near') paintAllTerms(); + syncTyping(); + } + updateMinimapView(); + } + + function fitAll(floor) { + const r = stageRect(); + const b = worldBounds(); + let s = Math.min((r.width - FIT_PAD * 2) / b.w, (r.height - FIT_PAD * 2) / b.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 - (b.minX + b.w / 2) * s, + y: r.height / 2 - (b.minY + b.h / 2) * s, + scale: s, + }; + applyTransform(); + } + + function zoomAt(clientX, clientY, factor) { + const r = stageRect(); + 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(); + } + + // The panel covers the right of the stage, so "centre" means the centre of what + // the user can still see. + function centreTransform(wx, wy, scale) { + const r = stageRect(); + const inset = el.panel.classList.contains('is-open') ? 370 : 0; + return { x: (r.width - inset) / 2 - wx * scale, y: r.height / 2 - wy * scale, scale }; + } + + function animateTo(target) { + if (anim) cancelAnimationFrame(anim); + const from = { x: tf.x, y: tf.y, scale: tf.scale }; + const t0 = performance.now(); + const ease = (u) => (u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2); + const step = (now) => { + const u = Math.min(1, (now - t0) / 300); + const e = REDUCED ? 1 : ease(u); + tf = { + x: from.x + (target.x - from.x) * e, + y: from.y + (target.y - from.y) * e, + scale: from.scale + (target.scale - from.scale) * e, + }; + applyTransform(); + anim = u < 1 && !REDUCED ? requestAnimationFrame(step) : null; + }; + anim = requestAnimationFrame(step); + } + + // ---- terminals ------------------------------------------------------------- + + function paintTile(st) { + if (band !== 'near') return; + const t = tileOf.get(st.agent.id); + const box = t && t.node.querySelector('.t-term'); + if (box) box.innerHTML = termHtml(st, TERM_LINES); + } + + function paintAllTerms() { + for (const t of tiles) { + const box = t.node.querySelector('.t-term'); + if (box) box.innerHTML = termHtml(streamOf(t.a.id), TERM_LINES); + } + } + + const timers = new Map(); + + function startTyping(st) { + const handle = {}; + const period = 900 + (st.h % 1700); + handle.start = window.setTimeout( + () => { + const tick = () => { + st.lines = st.lines.concat(pull(st, 1)); + trim(st); + paintTile(st); + if (selectedId === st.agent.id) paintPanelTail(st); + }; + tick(); + handle.tick = window.setInterval(tick, period); + }, + Math.random() * 1800, + ); + timers.set(st.agent.id, handle); + } + + // Typing is expensive and invisible below the near band, so it only runs for the + // tiles you can actually read — plus the one open in the panel. + function syncTyping() { + const want = new Set(); + if (!REDUCED) { + if (band === 'near') { + for (const a of F.buckets.working) if (!a.loopSleeping) want.add(a.id); + } + const sel = selectedId ? byId.get(selectedId) : null; + if (sel && sel.state === 'working' && !sel.loopSleeping) want.add(sel.id); + } + for (const [id, h] of timers) { + if (want.has(id)) continue; + window.clearTimeout(h.start); + window.clearInterval(h.tick); + timers.delete(id); + } + for (const id of want) if (!timers.has(id)) startTyping(streamOf(id)); + } + + // ---- minimap --------------------------------------------------------------- + + function renderMinimap() { + const b = worldBounds(); + el.mmsvg.setAttribute('viewBox', [b.minX, b.minY, b.w, b.h].join(' ')); + el.mmislands.innerHTML = islands + .map((i) => { + const p = islandPos(i); + return ( + '' + ); + }) + .join(''); + // r in world units so the dot lands at ~3px on screen once the viewBox is fitted. + el.mmdots.innerHTML = tiles + .map((t) => { + const c = tileCentre(t); + return ( + '' + ); + }) + .join(''); + updateMinimapView(); + } + + function updateMinimapView() { + const r = stageRect(); + el.mmview.setAttribute('x', (-tf.x / tf.scale).toFixed(0)); + el.mmview.setAttribute('y', (-tf.y / tf.scale).toFixed(0)); + el.mmview.setAttribute('width', (r.width / tf.scale).toFixed(0)); + el.mmview.setAttribute('height', (r.height / tf.scale).toFixed(0)); + } + + function minimapWorldPoint(e) { + const m = el.mmsvg.getScreenCTM(); + if (!m) return null; + return new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()); + } + + // ---- tooltip --------------------------------------------------------------- + + function showTip(node, a) { + el.tip.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')'); + el.tip.innerHTML = + '
' + esc(a.shortName) + '
' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '
' + esc(a.displayProject) + ' / ' + esc(a.worktree) + + '
started ' + F.ageLabel(a.startedAgo) + ' ago · ran ' + F.ageLabel(a.durationMs) + + '
' + esc(a.detail || '—') + '
'; + el.tip.hidden = false; + + const r = node.getBoundingClientRect(); + const t = el.tip.getBoundingClientRect(); + // Above the tile 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'; + } + + const 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 a cross-island edge reads as an arc rather + // than a wire drawn straight through everything between the two tiles. + const bow = Math.min(len * 0.14, 110); + 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 || !tileOf.has(selectedId)) { + el.lineage.innerHTML = ''; + return; + } + const b = worldBounds(); + el.lineage.setAttribute('viewBox', [b.minX, b.minY, b.w, b.h].join(' ')); + el.lineage.style.left = b.minX + 'px'; + el.lineage.style.top = b.minY + 'px'; + el.lineage.style.width = b.w + 'px'; + el.lineage.style.height = b.h + 'px'; + + const sel = byId.get(selectedId); + const from = tileCentre(tileOf.get(selectedId)); + const kin = (childrenOf.get(selectedId) || []).slice(); + if (sel.parent && byId.has(sel.parent)) kin.unshift(sel.parent); + + let out = ''; + for (const id of kin) { + if (id === selectedId || !tileOf.has(id)) continue; + const to = tileCentre(tileOf.get(id)); + out += + '' + + ''; + } + el.lineage.innerHTML = out; + } + + // ---- selection panel ------------------------------------------------------- + + 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) : '
' + (esc(l) || ' ') + '
')) + .join(''); + } + + function respondHtml(a) { + if (a.state === 'attention') { + const opts = a.question.options + .map( + (o, i) => + '', + ) + .join(''); + return ( + '

' + esc(a.question.text) + '

' + opts + '
' + + '
' + + '
' + ); + } + if (a.state === 'permission') { + const d = F.decisions.find((x) => x.key === a.decisionKey); + return ( + '

$ ' + esc(d ? d.command : '') + '

' + + '
' + + '' + + '
' + ); + } + if (a.state === 'failed') { + return ( + '

' + esc(a.failure) + '

' + + '
' + + '
' + ); + } + if (a.state === 'review') { + return ( + '
' + + '
' + ); + } + if (a.state === 'working') { + return ( + '
' + + '
' + ); + } + return '
'; + } + + function renderPanel(a) { + if (!a) { + el.panel.classList.remove('is-open'); + el.panel.setAttribute('aria-hidden', 'true'); + el.stage.style.setProperty('--panel-inset', '0px'); + return; + } + el.panel.style.setProperty('--hue', 'var(' + F.stateColorVar(a.state) + ')'); + el.panel.innerHTML = + '' + + '
' + + '

' + esc(a.shortName) + '

' + + '
' + STATE_LABEL[a.state] + + '· ' + liveness(a) + '
' + + '

' + esc(a.displayProject) + ' / ' + esc(a.worktree) + '

' + + '
' + + '
Started
' + clock(a.startedAt) + '
' + + '
Elapsed
' + F.ageLabel(a.startedAgo) + '
' + + '
Duration
' + F.ageLabel(a.durationMs) + '
' + + '
' + + '
Tool
' + esc(a.detail || '—') + '
' + + '
' + + '
' + + '
' + respondHtml(a) + '
'; + + paintPanelTail(streamOf(a.id)); + el.panel.classList.add('is-open'); + el.panel.setAttribute('aria-hidden', 'false'); + el.stage.style.setProperty('--panel-inset', '370px'); + el.panel.querySelector('.panel-close').addEventListener('click', () => select(null)); + } + + function select(id) { + const prev = selectedId && tileOf.get(selectedId); + if (prev) prev.node.classList.remove('is-selected'); + selectedId = id; + const next = id && tileOf.get(id); + if (next) next.node.classList.add('is-selected'); + renderPanel(id ? byId.get(id) : null); + drawLineage(); + syncTyping(); + } + + // ---- search ---------------------------------------------------------------- + + function applySearch(raw) { + const q = raw.trim().toLowerCase(); + let hits = 0; + for (const t of tiles) { + const a = t.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. + t.node.classList.toggle('is-dim', Boolean(q) && !hit); + if (hit && q) hits++; + } + const dead = Boolean(q) && hits === 0; + el.empty.hidden = !dead; + if (dead) el.empty.textContent = 'No agents match “' + raw.trim() + '”'; + } + + // ---- needs-you cycle ------------------------------------------------------- + + const rank = new Map(); + let ri = 0; + for (const g of F.byWorktree) for (const a of g.agents) rank.set(a.id, ri++); + const needsYou = F.buckets.needsYou + .slice() + .sort( + (x, y) => F.severityRank(x.state) - F.severityRank(y.state) || rank.get(x.id) - rank.get(y.id), + ); + let focusIdx = -1; + + function focusNext() { + if (!needsYou.length) return; + focusIdx = (focusIdx + 1) % needsYou.length; + const a = needsYou[focusIdx]; + select(a.id); + const c = tileCentre(tileOf.get(a.id)); + animateTo(centreTransform(c.x, c.y, FOCUS_SCALE)); + userMoved = true; + } + + // ---- drag: pan the view, arrange the furniture ----------------------------- + + let drag = null; + let suppressClick = false; + let userMoved = false; + + function updateResetButton() { + el.reset.disabled = Object.keys(deltas).length === 0; + } + + el.stage.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + if (e.target.closest('.viewtools') || e.target.closest('.minimap')) return; + userMoved = true; + suppressClick = false; + const head = e.target.closest('.isl-head'); + const label = e.target.closest('.wt-label'); + if (head) { + const isl = islandByName.get(head.parentNode.dataset.island); + drag = { mode: 'island', isl, key: 'island:' + isl.name }; + } else if (label) { + const g = groupByKey.get(label.parentNode.dataset.group); + drag = { mode: 'group', g, key: 'wt:' + g.key }; + } else { + drag = { mode: 'pan', tx: tf.x, ty: tf.y }; + el.stage.classList.add('is-panning'); + } + drag.sx = e.clientX; + drag.sy = e.clientY; + drag.moved = false; + if (drag.key) { + const d = deltaOf(drag.key); + drag.d0 = { dx: d.dx, dy: d.dy }; + e.preventDefault(); // a header drag must not start a text selection + } + }); + + window.addEventListener('mousemove', (e) => { + if (!drag) return; + const dx = e.clientX - drag.sx; + const dy = e.clientY - drag.sy; + if (!drag.moved) { + if (Math.abs(dx) <= DRAG_THRESHOLD_PX && Math.abs(dy) <= DRAG_THRESHOLD_PX) return; + drag.moved = true; + hideTip(); + if (drag.mode !== 'pan') el.stage.classList.add('is-arranging'); + } + if (drag.mode === 'pan') { + tf.x = drag.tx + dx; + tf.y = drag.ty + dy; + applyTransform(); + return; + } + // Furniture moves in world units, so it tracks the cursor at any zoom. + deltas[drag.key] = { dx: drag.d0.dx + dx / tf.scale, dy: drag.d0.dy + dy / tf.scale }; + if (drag.mode === 'island') { + positionIsland(drag.isl); + } else { + drag.g.node.classList.add('is-arranged'); + positionGroup(drag.g); + } + drawLineage(); + }); + + window.addEventListener('mouseup', () => { + if (!drag) return; + const done = drag; + drag = null; + el.stage.classList.remove('is-panning', 'is-arranging'); + suppressClick = done.moved; + if (done.mode === 'pan' || !done.moved) return; + // Dots are static except when the furniture moves, so this is the one place + // the minimap has to redraw them. + saveLayout(); + updateResetButton(); + renderMinimap(); + }); + + // ---- minimap navigation ---------------------------------------------------- + + let mmDragging = false; + + function recentreFrom(e) { + const p = minimapWorldPoint(e); + if (!p) return; + const t = centreTransform(p.x, p.y, tf.scale); + tf = t; + applyTransform(); + userMoved = true; + } + + el.minimap.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); + mmDragging = true; + recentreFrom(e); + }); + window.addEventListener('mousemove', (e) => { + if (mmDragging) recentreFrom(e); + }); + window.addEventListener('mouseup', () => { + mmDragging = false; + }); + + // ---- wiring ---------------------------------------------------------------- + + renderChrome(); + buildWorld(); + renderMinimap(); + updateResetButton(); + + // 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(); + userMoved = true; + // 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 drag is already flagged here. + if (suppressClick) { + suppressClick = false; + return; + } + if (e.target.closest('.viewtools') || e.target.closest('.minimap')) return; + const node = e.target.closest('.tile'); + 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('.tile') || e.target.closest('.minimap')) return; + fitAll(FIT_FLOOR); + }); + + el.stage.addEventListener('mouseover', (e) => { + if (drag) return; + const node = e.target.closest('.tile'); + if (node) showTip(node, byId.get(node.dataset.agent)); + }); + el.stage.addEventListener('mouseout', (e) => { + if (e.target.closest('.tile')) hideTip(); + }); + el.stage.addEventListener('focusin', (e) => { + const node = e.target.closest('.tile'); + if (node) showTip(node, byId.get(node.dataset.agent)); + }); + el.stage.addEventListener('focusout', hideTip); + el.stage.addEventListener('keydown', (e) => { + const node = e.target.closest('.tile'); + if (node && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + select(node.dataset.agent === selectedId ? null : node.dataset.agent); + } + }); + + el.fit.addEventListener('click', () => { + userMoved = true; + fitAll(FIT_FLOOR); + }); + el.reset.addEventListener('click', () => { + for (const k of Object.keys(deltas)) delete deltas[k]; + try { + window.localStorage.removeItem(LAYOUT_KEY); + } catch (err) { + /* nothing persisted, nothing to clear */ + } + for (const i of islands) positionIsland(i); + for (const g of groups) { + g.node.classList.remove('is-arranged'); + positionGroup(g); + } + updateResetButton(); + renderMinimap(); + 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; + } + const typing = e.target === el.search || e.target instanceof HTMLInputElement; + if (e.key.toLowerCase() === 'f' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + focusNext(); + 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. + window.addEventListener('resize', () => { + rectCache = null; + if (!userMoved) fitAll(FIT_FLOOR); + else updateMinimapView(); + }); + + // Pre-seed: frame the whole fleet in the mid band, then open the one needs-you + // agent whose parent lives in another repo — the first paint carries a + // cross-island lineage arc and the numbered reply panel. + fitAll(FIT_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]; + if (seed) { + select(seed.id); + focusIdx = needsYou.indexOf(seed); + } + userMoved = false; +})(); diff --git a/agent-fleet-control-room/fleet-tiles.png b/agent-fleet-control-room/fleet-tiles.png new file mode 100644 index 00000000000..5421e1e1bfc Binary files /dev/null and b/agent-fleet-control-room/fleet-tiles.png differ diff --git a/agent-fleet-control-room/fleet-triage.css b/agent-fleet-control-room/fleet-triage.css new file mode 100644 index 00000000000..17dbf397ad3 --- /dev/null +++ b/agent-fleet-control-room/fleet-triage.css @@ -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); +} diff --git a/agent-fleet-control-room/fleet-triage.html b/agent-fleet-control-room/fleet-triage.html new file mode 100644 index 00000000000..750d749f5a2 --- /dev/null +++ b/agent-fleet-control-room/fleet-triage.html @@ -0,0 +1,27 @@ + + + + + + Fleet — Triage + + + + +
+ +
+
+ + + + + + diff --git a/agent-fleet-control-room/fleet-triage.js b/agent-fleet-control-room/fleet-triage.js new file mode 100644 index 00000000000..cb6cf92379b --- /dev/null +++ b/agent-fleet-control-room/fleet-triage.js @@ -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]); +})(); diff --git a/agent-fleet-control-room/fleet-triage.png b/agent-fleet-control-room/fleet-triage.png new file mode 100644 index 00000000000..e31829a44a0 Binary files /dev/null and b/agent-fleet-control-room/fleet-triage.png differ diff --git a/agent-fleet-control-room/mission-control.css b/agent-fleet-control-room/mission-control.css new file mode 100644 index 00000000000..12967bcdcb7 --- /dev/null +++ b/agent-fleet-control-room/mission-control.css @@ -0,0 +1,1030 @@ +/* Mission Control — deterministic containment map (left) fused to an intervention rail (right). + Chrome is monochrome; the only saturated hue on screen is an agent's --state-* token, and it + never appears without a glyph shape carrying the same meaning. */ + +html, +body { + height: 100%; + overflow: hidden; +} + +.app { + display: grid; + grid-template-rows: 52px minmax(0, 1fr) 28px; + height: 100vh; + min-width: 1280px; +} + +/* ---------------------------------------------------------------- top bar */ + +.topbar { + display: flex; + align-items: center; + gap: 20px; + padding: 0 14px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 55%, var(--background)); +} + +.brand { + display: flex; + align-items: center; + gap: 14px; + flex: 0 0 auto; +} + +.brand-title { + margin: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.tabs { + display: flex; + align-items: center; + gap: 2px; + padding: 2px; + background: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.tab { + padding: 3px 9px; + border-radius: 4px; + font-size: 11px; + color: var(--muted-foreground); + text-decoration: none; + white-space: nowrap; + transition: color 0.12s ease, background 0.12s ease; +} + +.tab:hover { + color: var(--foreground); + background: color-mix(in srgb, var(--accent) 45%, transparent); +} + +.tab.is-active { + color: var(--foreground); + background: var(--accent); +} + +/* Dials — numeral first, label under. NEEDS YOU is the only one allowed to glow. */ + +.dials { + display: flex; + align-items: stretch; + gap: 6px; + flex: 0 0 auto; +} + +.dial { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + min-width: 74px; + padding: 3px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--card); +} + +.dial-value { + font-size: 20px; + font-weight: 600; + line-height: 1.05; + font-variant-numeric: tabular-nums; + color: var(--foreground); +} + +.dial-label { + white-space: nowrap; +} + +.dial[data-tone='attention'][data-live='true'] { + border-color: color-mix(in srgb, var(--state-attention) 60%, var(--border)); + box-shadow: var(--glow-attention); + background: color-mix(in srgb, var(--state-attention) 7%, var(--card)); +} + +.dial[data-tone='review'] { + border-color: color-mix(in srgb, var(--state-done) 30%, var(--border)); + background: color-mix(in srgb, var(--state-done) 5%, var(--card)); +} + +.dial[data-tone='quiet'] { + background: transparent; +} + +.dial[data-tone='quiet'] .dial-value { + color: var(--muted-foreground); + font-weight: 500; +} + +.topbar-end { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; + flex: 0 1 auto; + min-width: 0; +} + +.accounting { + font-size: 11px; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; + white-space: nowrap; + padding: 3px 9px; + border: 1px solid var(--border); + border-radius: 999px; +} + +.search { + display: flex; + align-items: center; + gap: 6px; + width: 232px; + padding: 0 8px; + height: 28px; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.search:focus-within { + border-color: var(--input); +} + +.search-glyph { + color: var(--muted-foreground); + font-size: 12px; +} + +.search input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--foreground); + font: inherit; + font-size: 12px; +} + +.search input::placeholder { + color: var(--muted-foreground); +} + +.search input::-webkit-search-cancel-button { + appearance: none; +} + +/* ------------------------------------------------------------------ board */ + +.board { + display: grid; + grid-template-columns: 62fr 38fr; + min-height: 0; +} + +/* --------------------------------------------------------- containment map */ + +.map { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + padding: 10px 12px 12px; + overflow: hidden; +} + +.bands { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + flex: 1; +} + +.band { + --band-tint: var(--state-idle); + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid color-mix(in srgb, var(--band-tint) 22%, var(--border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 42%, var(--background)); +} + +.band[data-grow='true'] { + flex: 1 1 auto; +} + +/* Group annunciation: a band holding an unacknowledged needs-you item breathes at its own + slow tempo — deliberately below the item-level blink so it reads as context, not alarm. */ +@keyframes band-annunciate { + 0%, + 100% { + border-color: color-mix(in srgb, var(--band-tint) 46%, var(--border)); + } + 50% { + border-color: color-mix(in srgb, var(--band-tint) 12%, var(--border)); + } +} + +.band[data-annunciate='true'] { + animation: band-annunciate 4.2s ease-in-out infinite; +} + +.band-head { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px 6px; + flex: 0 0 auto; +} + +.band-head .eyebrow { + font-variant-numeric: tabular-nums; +} + +.band-worst { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + font-size: 10px; + color: var(--muted-foreground); + letter-spacing: 0.04em; +} + +.cells { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + align-content: start; + gap: 5px; + padding: 0 8px 8px; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.cell { + padding: 5px 7px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--card); + transition: border-color 0.12s ease, opacity 0.12s ease; +} + +.cell:hover { + border-color: var(--input); +} + +.cell.is-dimmed { + opacity: 0.16; +} + +.cell.is-hit { + border-color: var(--ring); +} + +.cell-head { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 5px; +} + +.cell-name { + flex: 1; + min-width: 0; + font-size: 11px; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cell-unread { + flex: 0 0 auto; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--primary); +} + +.cell-count { + flex: 0 0 auto; + min-width: 10px; + text-align: right; + font-size: 10px; + font-variant-numeric: tabular-nums; + color: var(--muted-foreground); +} + +.cell-glyphs { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +/* ----------------------------------------------------------------- glyphs */ +/* Rounded square, state hue at 14% for fill and full strength on the 1.5px rule, with the + state's shape centred inside. Hue and shape always travel together. */ + +.glyph { + --gc: var(--state-idle); + position: relative; + display: inline-grid; + place-items: center; + width: 14px; + height: 14px; + padding: 0; + border: 1.5px solid var(--gc); + border-radius: 4px; + background: color-mix(in srgb, var(--gc) 14%, var(--card)); + color: var(--gc); + font-family: var(--font-ui); + font-size: 8px; + line-height: 1; + cursor: default; + transition: transform 0.1s ease; +} + +button.glyph { + cursor: pointer; +} + +button.glyph:hover { + transform: scale(1.18); + z-index: 2; +} + +.glyph:focus-visible { + outline: 1px solid var(--ring); + outline-offset: 2px; +} + +.glyph[data-state='attention'], +.glyph[data-state='permission'] { + --gc: var(--state-attention); +} +.glyph[data-state='failed'] { + --gc: var(--state-blocked); +} +.glyph[data-state='working'] { + --gc: var(--state-working); + opacity: 0.65; /* working is the resting majority — it must not compete with amber/red */ +} +.glyph[data-state='review'] { + --gc: var(--state-done); +} +.glyph[data-state='done'] { + --gc: var(--state-idle); + color: var(--muted-foreground); +} + +/* Liveness is its own channel, independent of state: solid = alive, hollow = exited. */ +.glyph[data-live='exited'] { + background: transparent; + border-style: dashed; + opacity: 0.5; +} + +.glyph[data-state='working'][data-live='exited'] { + opacity: 0.34; +} + +.g-ring { + width: 7px; + height: 7px; + border: 1.5px solid var(--gc); + border-top-color: transparent; + border-radius: 50%; + animation: spin 5.5s linear infinite; +} + +.g-crescent { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--gc); + -webkit-mask: radial-gradient(circle at 122% 50%, transparent 62%, #000 63%); + mask: radial-gradient(circle at 122% 50%, transparent 62%, #000 63%); +} + +.glyph[data-live='exited'] .g-ring { + animation: none; +} + +/* Motion is rationed: only unacknowledged needs-you blinks fast, only unreviewed work + pulses slow. Everything else holds still, so a quiet screen means a quiet fleet. */ +.glyph[data-motion='blink'] { + animation: blink-border-attention var(--blink-fast) step-end infinite; +} + +.glyph[data-motion='blink'][data-state='failed'] { + animation-name: blink-border-blocked; +} + +.glyph[data-motion='pulse'] { + animation: pulse-review var(--pulse-slow) ease-in-out infinite; +} + +.glyph.is-selected { + outline: 1.5px solid var(--ring); + outline-offset: 2px; +} + +.glyph.is-linked { + outline: 1px dashed var(--ring); + outline-offset: 2px; +} + +.glyph.is-dimmed { + opacity: 0.12; +} + +/* Lineage overlay — drawn on demand for the selected agent only. */ +.lineage { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; +} + +.lineage path { + fill: none; + stroke: var(--ring); + stroke-width: 1; +} + +.lineage circle { + fill: var(--ring); +} + +.map-empty { + position: absolute; + inset: 40% 0 auto; + margin: 0; + text-align: center; + font-size: 12px; + color: var(--muted-foreground); +} + +/* ------------------------------------------------------- intervention rail */ + +.rail { + display: flex; + flex-direction: column; + gap: 7px; + min-height: 0; + padding: 10px 12px 14px; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 30%, var(--background)); + overflow-y: auto; + overscroll-behavior: contain; +} + +.rail-head { + display: flex; + align-items: center; + gap: 8px; + padding-top: 2px; +} + +.rail-head:not(:first-child) { + margin-top: 6px; +} + +.rail-head .eyebrow { + font-variant-numeric: tabular-nums; +} + +.cycle { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + padding: 2px 7px 2px 4px; + font-size: 10px; + color: var(--muted-foreground); + background: transparent; + border: 1px solid var(--border); + border-radius: 999px; + cursor: pointer; + transition: color 0.12s ease, border-color 0.12s ease; +} + +.cycle:hover { + color: var(--foreground); + border-color: var(--input); +} + +.cycle[data-exhausted='true'] { + opacity: 0.4; + cursor: default; +} + +/* One card tier only — nothing inside a card is itself a card. */ +.item { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + padding: 8px 10px 9px; +} + +.item[data-motion='blink'] { + animation: blink-border-attention var(--blink-fast) step-end infinite; +} + +.item[data-motion='blink'][data-kind='failed'] { + animation-name: blink-border-blocked; +} + +.item[data-motion='pulse'] { + animation: pulse-review var(--pulse-slow) ease-in-out infinite; +} + +.item.is-selected { + outline: 1px solid var(--ring); + outline-offset: 1px; +} + +.item.is-flash { + border-color: var(--ring); +} + +.item-head { + display: flex; + align-items: center; + gap: 7px; +} + +.item-title { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-name { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-where { + flex: 0 1 auto; + min-width: 0; + font-size: 11px; + color: var(--muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-age { + flex: 0 0 auto; + margin-left: auto; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--muted-foreground); + text-align: right; +} + +.chip-selected { + flex: 0 0 auto; + padding: 1px 6px; + font-size: 9.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted-foreground); + border: 1px solid var(--ring); + border-radius: 999px; + white-space: nowrap; +} + +.command { + display: block; + margin: 7px 0 6px; + padding: 4px 8px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--primary); + background: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta { + font-size: 11px; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; +} + +/* Micro-rows: the agents a single decision would unblock. Flat rows, never nested cards. */ +.microrows { + margin: 8px 0 0; + padding: 0 0 0 8px; + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 3px; +} + +.microrow { + display: flex; + align-items: center; + gap: 7px; + padding: 1px 0; + cursor: pointer; +} + +.microrow .item-name { + font-size: 11px; +} + +.microrow.is-selected .item-name { + color: var(--foreground); +} + +.microrow.is-selected::after { + content: ''; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--ring); + flex: 0 0 auto; +} + +.microrow:hover .item-name, +.microrow:hover .item-where { + color: var(--foreground); +} + +.evidence { + margin-top: 7px; + padding-left: 8px; + font-size: 10.5px; + line-height: 1.4; + color: var(--muted-foreground); + border-left: 1px solid var(--border); +} + +.evidence b { + font-weight: 600; + color: var(--primary); +} + +.question { + margin: 6px 0 7px; + font-size: 11.5px; + line-height: 1.45; + color: var(--primary); +} + +.options { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 7px; +} + +.option { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + padding: 5px 8px; + text-align: left; + font: inherit; + font-size: 11.5px; + color: var(--foreground); + background: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease; +} + +.option:hover { + border-color: var(--input); + background: var(--secondary); +} + +.reply { + display: flex; + gap: 6px; +} + +.reply input { + flex: 1; + min-width: 0; + height: 26px; + padding: 0 8px; + font: inherit; + font-size: 11.5px; + color: var(--foreground); + background: var(--background); + border: 1px solid var(--input); + border-radius: var(--radius-sm); + outline: 0; +} + +.reply input::placeholder { + color: var(--muted-foreground); +} + +/* Scope segmented control — the blast radius of the answer, chosen before it is given. */ +.scope { + display: flex; + align-items: center; + gap: 8px; + margin: 9px 0 8px; +} + +.segmented { + display: flex; + padding: 2px; + background: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.segment { + padding: 3px 9px; + font: inherit; + font-size: 11px; + color: var(--muted-foreground); + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + transition: color 0.12s ease, background 0.12s ease; +} + +.segment:hover { + color: var(--foreground); +} + +.segment[aria-pressed='true'] { + color: var(--foreground); + background: var(--accent); +} + +.actions { + display: flex; + align-items: center; + gap: 6px; + margin-top: 7px; +} + +.btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 26px; + padding: 0 11px; + font: inherit; + font-size: 11.5px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--secondary); + color: var(--foreground); + cursor: pointer; + transition: border-color 0.12s ease, background 0.12s ease; +} + +.btn:hover { + border-color: var(--input); +} + +.btn-primary { + background: var(--primary); + color: var(--primary-foreground); + border-color: transparent; + font-weight: 600; +} + +.btn-primary:hover { + background: var(--foreground); +} + +.btn-ghost { + background: transparent; + color: var(--muted-foreground); +} + +.btn-ghost:hover { + color: var(--foreground); +} + +.failure { + margin: 6px 0 0; + padding-left: 8px; + border-left: 1px solid color-mix(in srgb, var(--state-blocked) 40%, var(--border)); + font-family: var(--font-mono); + font-size: 10.5px; + line-height: 1.45; + color: var(--muted-foreground); +} + +.pr-badge { + flex: 0 0 auto; + padding: 1px 6px; + font-size: 10px; + font-variant-numeric: tabular-nums; + color: var(--muted-foreground); + border: 1px solid var(--border); + border-radius: 999px; +} + +.rail-empty, +.fold { + padding: 7px 2px 2px; + font-size: 11px; + color: var(--muted-foreground); +} + +.fold { + border-top: 1px solid var(--border); + margin-top: 4px; +} + +.resolved { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-size: 11px; + color: var(--muted-foreground); + border: 1px dashed var(--border); + border-radius: var(--radius); +} + +/* --------------------------------------------------------------- tooltip */ + +.tooltip { + position: fixed; + z-index: 40; + width: 268px; + padding: 9px 11px 10px; + background: var(--popover); + border: 1px solid var(--input); + border-radius: var(--radius); + box-shadow: var(--shadow-floating); + pointer-events: none; +} + +.tooltip-name { + font-size: 11.5px; + font-weight: 600; + line-height: 1.35; + color: var(--foreground); +} + +.tooltip-line { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 4px; + font-size: 11px; + color: var(--muted-foreground); + font-variant-numeric: tabular-nums; +} + +.tooltip-line .eyebrow { + flex: 0 0 46px; +} + +.tooltip-line span { + flex: 1; + min-width: 0; + color: var(--primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tooltip-tail { + margin-top: 7px; + padding-top: 7px; + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.5; + color: var(--muted-foreground); + white-space: pre-wrap; + word-break: break-word; +} + +/* ------------------------------------------------------------ status strip */ + +.statusbar { + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 55%, var(--background)); + font-size: 10px; + color: var(--muted-foreground); + overflow: hidden; +} + +.legend { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.legend span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.legend .glyph { + cursor: default; + transform: none; +} + +.legend .glyph:hover { + transform: none; +} + +.swatch { + width: 9px; + height: 9px; + border-radius: 3px; + border: 1.5px solid var(--border); +} + +.swatch-blink { + border-color: var(--state-attention); + animation: blink-border-attention var(--blink-fast) step-end infinite; +} + +.swatch-pulse { + border-color: color-mix(in srgb, var(--state-done) 55%, var(--border)); + animation: pulse-review var(--pulse-slow) ease-in-out infinite; +} + +.legend-keys { + gap: 10px; +} + +.legend-keys span { + gap: 4px; +} + +.capture { + flex: 0 0 auto; + margin-left: auto; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.capture[data-paused='true'] { + color: var(--primary); +} + +/* Thin, unobtrusive scrollbars — the map must not look like a scrolling document. */ +.cells::-webkit-scrollbar, +.rail::-webkit-scrollbar { + width: 8px; +} + +.cells::-webkit-scrollbar-thumb, +.rail::-webkit-scrollbar-thumb { + background: var(--accent); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} + +.cells::-webkit-scrollbar-track, +.rail::-webkit-scrollbar-track { + background: transparent; +} diff --git a/agent-fleet-control-room/mission-control.html b/agent-fleet-control-room/mission-control.html new file mode 100644 index 00000000000..4c943565f73 --- /dev/null +++ b/agent-fleet-control-room/mission-control.html @@ -0,0 +1,68 @@ + + + + + + Mission Control — Orca agent fleet + + + + +
+
+
+

Mission Control

+ +
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+ + +
+ +
+
+
+ fast = needs you + slow = to review + still = settled +
+
+
+
+
+ + + + + + + + diff --git a/agent-fleet-control-room/mission-control.js b/agent-fleet-control-room/mission-control.js new file mode 100644 index 00000000000..d33766136c9 --- /dev/null +++ b/agent-fleet-control-room/mission-control.js @@ -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 ' + escapeHtml(d.command) + '']; + const types = [...new Set(agents.map((a) => a.agentType))]; + if (types.length === 1) bits.push('all ' + escapeHtml(types[0]) + ''); + 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 ' + escapeHtml(projects[0]) + '' + : worktrees + ' worktrees across ' + escapeHtml(projects.join(' + ')) + '' + ); + 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); +})(); diff --git a/agent-fleet-control-room/mission-control.png b/agent-fleet-control-room/mission-control.png new file mode 100644 index 00000000000..8c1cf593254 Binary files /dev/null and b/agent-fleet-control-room/mission-control.png differ diff --git a/agent-fleet-control-room/research-agent-product-survey-v1.md b/agent-fleet-control-room/research-agent-product-survey-v1.md new file mode 100644 index 00000000000..a245862868c --- /dev/null +++ b/agent-fleet-control-room/research-agent-product-survey-v1.md @@ -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` diff --git a/agent-fleet-control-room/research-agent-product-survey-v2.md b/agent-fleet-control-room/research-agent-product-survey-v2.md new file mode 100644 index 00000000000..c1b290ee692 --- /dev/null +++ b/agent-fleet-control-room/research-agent-product-survey-v2.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. diff --git a/agent-fleet-refined/README.md b/agent-fleet-refined/README.md new file mode 100644 index 00000000000..c11d97e9ea1 --- /dev/null +++ b/agent-fleet-refined/README.md @@ -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. diff --git a/agent-fleet-refined/agent-command-deck.css b/agent-fleet-refined/agent-command-deck.css new file mode 100644 index 00000000000..66ce392c1e7 --- /dev/null +++ b/agent-fleet-refined/agent-command-deck.css @@ -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; } +} diff --git a/agent-fleet-refined/agent-command-deck.html b/agent-fleet-refined/agent-command-deck.html new file mode 100644 index 00000000000..e63a396aebf --- /dev/null +++ b/agent-fleet-refined/agent-command-deck.html @@ -0,0 +1,217 @@ + + + + + + Orca Agent Command Deck + + + + + + +
+
+
O
+
AgentsCommand deck
+ + + +
+ +
+ + +
+
+
+ Decision 1 of 4 +

Runtime cleanup behavior

+

One response will route to three waiting agents.

+
+
+ 3 agents + 2 worktrees + +
+
+ +
+
+ ? +
+ The decision +

Should stale local agent rows survive an Orca restart?

+

The implementation is complete. Reviewers disagree only on whether historical local rows remain visible after the backing terminal disappears.

+
+ 3 waiting +
+ +
+ Distilled context + Generated from 3 agent threads · refreshed 36s ago +
+
+
+
Everyone agrees
+

Remote and WSL history must survive relay reconnects. Active terminal rows must never be duplicated.

+
+
+
↔The disagreement
+

Keeping local history aids recovery; dropping it avoids presenting terminal state that no longer exists.

+
+
+
Evidence
+

418 tests pass. One restart fixture changes depending on the retention policy.

+ +
+
+ +
+
Your response goes to
+
+
R

Review coordinatorreview-11464 · waiting

primary
+
S

Security reviewpr-8783 · waiting

context
+
X

Cross-platform passpr-8783 · waiting

context
+
+
+
+ +
+
+ Respond once · route to 3 agents +
+
+ + +
+
+ + +
+
+ + + +
Direction sent to 3 agentsOpening the next decision…
+ + + diff --git a/agent-fleet-refined/agent-command-deck.js b/agent-fleet-refined/agent-command-deck.js new file mode 100644 index 00000000000..99e9128149d --- /dev/null +++ b/agent-fleet-refined/agent-command-deck.js @@ -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') + }) +}) diff --git a/agent-fleet-refined/agent-rings-focus-done.png b/agent-fleet-refined/agent-rings-focus-done.png new file mode 100644 index 00000000000..6bc6ccb1ab4 Binary files /dev/null and b/agent-fleet-refined/agent-rings-focus-done.png differ diff --git a/agent-fleet-refined/agent-rings-focus-terminal.png b/agent-fleet-refined/agent-rings-focus-terminal.png new file mode 100644 index 00000000000..5422218a565 Binary files /dev/null and b/agent-fleet-refined/agent-rings-focus-terminal.png differ diff --git a/agent-fleet-refined/agent-rings-focus.css b/agent-fleet-refined/agent-rings-focus.css new file mode 100644 index 00000000000..d88cd2943ca --- /dev/null +++ b/agent-fleet-refined/agent-rings-focus.css @@ -0,0 +1,1506 @@ +@font-face { + font-family: Geist; + src: url('../src/renderer/src/assets/fonts/Geist-Variable.woff2') format('woff2'); + font-weight: 100 900; +} + +:root { + --background: #fff; + --editor-surface: #fff; + --foreground: #0a0a0a; + --card: #fff; + --card-foreground: #0a0a0a; + --popover: #fff; + --popover-foreground: #0a0a0a; + --primary: #171717; + --primary-foreground: #fafafa; + --secondary: #f5f5f5; + --secondary-foreground: #171717; + --muted: #f5f5f5; + --muted-foreground: #737373; + --accent: #f5f5f5; + --accent-foreground: #171717; + --destructive: #e40014; + --destructive-foreground: #fcf3f3; + --border: #e5e5e5; + --input: #e5e5e5; + --ring: #a1a1a1; + --status-success: #15803d; + --radius: 10px; + --attention: #f59e0b; + --agent-working: oklch(79.5% 0.184 86.047); + --agent-waiting: oklch(76.9% 0.188 70.08); + --agent-blocked: oklch(63.7% 0.237 25.331); + --agent-done: oklch(69.6% 0.17 162.48); + --agent-idle: oklch(55.6% 0 0 / 0.4); +} + +.dark { + --background: #0a0a0a; + --editor-surface: #1e1e1e; + --foreground: #fafafa; + --card: #171717; + --card-foreground: #fafafa; + --popover: #171717; + --popover-foreground: #fafafa; + --primary: #e5e5e5; + --primary-foreground: #171717; + --secondary: #262626; + --secondary-foreground: #fafafa; + --muted: #262626; + --muted-foreground: #a1a1a1; + --accent: #404040; + --accent-foreground: #fafafa; + --destructive: #ff6568; + --destructive-foreground: #df2225; + --border: rgb(255 255 255 / 0.07); + --input: rgb(255 255 255 / 0.15); + --ring: #737373; + --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.4 Geist, + sans-serif; + letter-spacing: 0.01em; +} + +button, +input { + font: inherit; +} + +button { + color: inherit; + cursor: pointer; +} + +.svg-defs { + position: absolute; + width: 0; + height: 0; +} + +.icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.eyebrow { + color: var(--muted-foreground); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + 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; + white-space: nowrap; +} + +.app-shell { + position: relative; + min-width: 1120px; + height: 100vh; + min-height: 720px; + overflow: hidden; + background: var(--background); +} + +.titlebar { + height: 45px; + display: flex; + align-items: center; + padding: 0 18px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 82%, var(--background)); +} + +.titlebar h1 { + margin: 0 0 0 24px; + color: var(--muted-foreground); + font-size: 14px; + font-weight: 650; +} + +.window-controls { + display: flex; + gap: 9px; +} + +.window-controls i { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--destructive); +} + +.window-controls i:nth-child(2) { + background: var(--attention); +} + +.window-controls i:nth-child(3) { + background: var(--status-success); +} + +.app-header { + height: 64px; + display: flex; + align-items: center; + gap: 20px; + padding: 0 22px; + border-bottom: 1px solid var(--border); +} + +.app-title { + display: flex; + align-items: baseline; + gap: 9px; +} + +.app-title strong { + font-size: 15px; +} + +.app-title span { + color: var(--muted-foreground); + font-size: 12px; +} + +.view-switcher, +.host-switcher { + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; +} + +.view-switcher button, +.host-switcher button { + height: 28px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted-foreground); + font-size: 12px; +} + +.view-switcher button:hover, +.view-switcher button.is-active, +.host-switcher button:hover, +.host-switcher button.is-active { + background: var(--accent); + color: var(--accent-foreground); +} + +.toolbar { + height: 54px; + display: flex; + align-items: center; + gap: 9px; + padding: 0 16px; + border-bottom: 1px solid var(--border); +} + +.search { + height: 32px; + display: flex; + flex: 1; + align-items: center; + gap: 8px; + padding: 0 9px; + border: 1px solid var(--input); + border-radius: 8px; + background: color-mix(in srgb, var(--muted) 55%, transparent); + color: var(--muted-foreground); +} + +.search:focus-within { + outline: 2px solid var(--ring); + outline-offset: 1px; +} + +.search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--foreground); + font-size: 12px; +} + +.toolbar-filter { + height: 32px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + font-size: 12px; +} + +.toolbar-filter:hover { + background: var(--accent); +} + +.toolbar-filter .chevron { + width: 13px; + transform: rotate(90deg); +} + +.fleet-layout { + height: calc(100vh - 163px); + display: grid; + grid-template-columns: 224px minmax(0, 1fr); +} + +.filter-rail { + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 17px 14px 12px; + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 35%, transparent); +} + +.filter-rail > header { + padding: 0 6px 16px; + border-bottom: 1px solid var(--border); +} + +.filter-rail h2 { + margin: 5px 0 4px; + font-size: 13px; +} + +.filter-rail p { + margin: 0; + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.55; +} + +.state-filters, +.finished-scopes, +.project-filters { + display: flex; + flex-direction: column; + padding: 15px 0; + border-bottom: 1px solid var(--border); +} + +.state-filters > .eyebrow, +.finished-scopes > .eyebrow, +.project-filters > .eyebrow { + padding: 0 7px 7px; +} + +.state-filter { + height: 31px; + display: grid; + grid-template-columns: 12px 1fr auto; + align-items: center; + gap: 8px; + padding: 0 8px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted-foreground); + text-align: left; + font-size: 12px; +} + +.state-filter:hover { + background: var(--accent); + color: var(--foreground); +} + +.state-filter.is-active { + background: var(--accent); + color: var(--accent-foreground); +} + +.state-filter b { + font-size: 11px; + font-weight: 550; + font-variant-numeric: tabular-nums; +} + +.state-mark { + width: 12px; + height: 12px; + display: grid; + justify-self: center; + place-items: center; +} + +.state-mark.working { + width: 10px; + height: 10px; + border: 2px solid var(--agent-working); + border-top-color: transparent; + border-radius: 50%; + animation: state-spinner 850ms linear infinite; +} + +.state-mark.waiting { + color: var(--agent-waiting); +} + +.state-mark.blocked { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--agent-blocked); +} + +.state-mark.done { + color: var(--agent-done); +} + +.state-mark .icon { + width: 12px; + height: 12px; +} + +.state-mark.all { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted-foreground); + opacity: 0.7; +} + +.scope-filter { + height: 29px; + display: flex; + align-items: center; + padding: 0 8px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted-foreground); + font-size: 11px; + text-align: left; +} + +.scope-filter:hover { + background: var(--accent); + color: var(--foreground); +} + +.scope-filter.is-active { + background: var(--accent); + color: var(--accent-foreground); +} + +.scope-filter:disabled { + cursor: default; + opacity: 0.38; +} + +.scope-filter b { + margin-left: auto; + font-size: 10px; + font-weight: 550; + font-variant-numeric: tabular-nums; +} + +.finished-scopes p { + display: flex; + align-items: center; + gap: 5px; + padding: 7px 8px 0; + font-size: 9px; +} + +.finished-scopes p .icon { + width: 11px; +} + +.project-filter { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 8px; + border: 0; + border-radius: 7px; + background: transparent; + text-align: left; +} + +.project-filter:hover { + background: var(--accent); +} + +.project-filter.is-active { + background: color-mix(in srgb, var(--accent) 55%, transparent); +} + +.project-filter > span { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; +} + +.project-filter strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-filter small { + color: var(--muted-foreground); + font-size: 10px; +} + +.project-filter b { + color: var(--muted-foreground); + font-size: 10px; + font-weight: 500; + white-space: nowrap; +} + +.project-filter em { + color: var(--foreground); + font-style: normal; + font-weight: 600; +} + +.filter-summary { + display: flex; + flex-direction: column; + gap: 2px; + margin: 12px 5px 0; + padding: 10px; + border: 1px solid var(--border); + border-radius: 9px; + background: color-mix(in srgb, var(--muted) 54%, transparent); +} + +.filter-summary strong { + color: var(--foreground); + font-size: 11px; +} + +.filter-summary button { + padding: 0; + border: 0; + background: transparent; + color: var(--muted-foreground); + font-size: 9px; + text-align: left; +} + +.filter-summary button:hover { + color: var(--foreground); + text-decoration: underline; +} + +.bulk-actions { + padding: 8px 5px 0; +} + +.bulk-actions button { + width: 100%; + min-height: 29px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 5px 7px; + border: 1px solid var(--border); + border-radius: 7px; + background: transparent; + color: var(--muted-foreground); + font-size: 9px; +} + +.bulk-actions button:hover { + background: var(--accent); + color: var(--foreground); +} + +.bulk-actions .icon { + width: 11px; +} + +.filter-rail footer { + display: flex; + gap: 6px; + margin-top: auto; + padding-top: 12px; +} + +.filter-rail footer button { + min-width: 0; + flex: 1; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + font-size: 12px; +} + +.filter-rail footer button:hover { + background: var(--accent); +} + +.canvas-column { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.canvas-header { + min-height: 54px; + display: flex; + align-items: center; + gap: 14px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); +} + +.canvas-header > div:first-child { + display: flex; + flex-direction: column; +} + +.canvas-header strong { + font-size: 12px; +} + +.hidden-summary { + height: 24px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: transparent; + color: var(--muted-foreground); + font-size: 10px; +} + +.hidden-summary:hover { + background: var(--accent); + color: var(--foreground); +} + +.hidden-summary .icon { + width: 12px; + color: var(--status-success); +} + +.host-switcher { + margin-left: auto; +} + +.host-switcher button { + height: 24px; + font-size: 10px; +} + +.rings-canvas { + position: relative; + min-height: 0; + flex: 1; + overflow: hidden; + background-image: + radial-gradient( + color-mix(in srgb, var(--muted-foreground) 14%, transparent) 0.7px, + transparent 0.7px + ), + radial-gradient( + circle at 43% 48%, + color-mix(in srgb, var(--muted) 28%, transparent), + transparent 64% + ); + background-size: + 20px 20px, + 100% 100%; +} + +.project-ring, +.workspace-ring { + position: absolute; + border: 1px solid color-mix(in srgb, var(--muted-foreground) 43%, transparent); + border-radius: 50%; + background: color-mix(in srgb, var(--card) 24%, transparent); + transition: + opacity 160ms ease, + transform 160ms ease; +} + +.project-ring.is-filtered-out, +.workspace-ring.is-filtered-out, +.agent-node.is-filtered-out, +.done-cluster.is-filtered-out { + display: none; +} + +.project-primary { + width: min(71%, 820px); + aspect-ratio: 1.22; + top: 50%; + left: 39%; + transform: translate(-50%, -50%); +} + +.project-secondary { + width: min(25%, 300px); + aspect-ratio: 1; + top: 48%; + right: 4%; + transform: translateY(-50%); +} + +.project-label { + position: absolute; + top: 5.5%; + left: 12%; + display: flex; + flex-direction: column; + pointer-events: none; +} + +.project-label strong { + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.project-label span { + color: var(--muted-foreground); + font-size: 10px; + text-transform: uppercase; +} + +.workspace-ring { + background: color-mix(in srgb, var(--card) 47%, transparent); +} + +.workspace-ring > header { + position: absolute; + top: 8%; + left: 50%; + display: flex; + width: 88%; + transform: translateX(-50%); + flex-direction: column; + align-items: center; + pointer-events: none; + text-align: center; +} + +.workspace-ring > header strong { + max-width: 100%; + overflow: hidden; + font-size: 10px; + letter-spacing: 0.04em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.workspace-ring > header span { + color: var(--muted-foreground); + font-size: 8px; + text-transform: uppercase; +} + +.workspace-large { + width: 38%; + aspect-ratio: 1; + top: 31%; + left: 31%; +} + +.workspace-top { + width: 25%; + aspect-ratio: 1; + top: 10%; + left: 57%; +} + +.workspace-mid { + width: 22%; + aspect-ratio: 1; + top: 45%; + left: 67%; +} + +.workspace-bottom { + width: 23%; + aspect-ratio: 1; + top: 72%; + left: 49%; +} + +.workspace-small { + width: 22%; + aspect-ratio: 1; + top: 62%; + left: 12%; +} + +.workspace-remote { + width: 72%; + aspect-ratio: 1; + top: 14%; + left: 14%; +} + +.agent-node { + --node-size: 50px; + position: absolute; + z-index: 3; + top: var(--y); + left: var(--x); + width: var(--node-size); + height: var(--node-size); + padding: 0; + transform: translate(-50%, -50%); + border: 0; + border-radius: 50%; + background: transparent; + outline: none; +} + +.agent-sm { + --node-size: 42px; +} + +.agent-md { + --node-size: 50px; +} + +.agent-lg { + --node-size: 59px; +} + +.agent-xl { + --node-size: 68px; +} + +.agent-avatar { + width: 100%; + height: 100%; + display: grid; + place-items: center; + border: 1.5px solid var(--foreground); + border-radius: 50%; + background: var(--background); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--foreground) 5%, transparent); + transition: + transform 140ms ease, + background 140ms ease, + box-shadow 140ms ease; +} + +.agent-avatar .icon { + width: calc(var(--node-size) * 0.38); + height: calc(var(--node-size) * 0.38); +} + +.agent-state { + position: absolute; + z-index: 4; + top: 1px; + left: -4px; + width: 14px; + height: 14px; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--background); +} + +.agent-state.working::before { + width: 9px; + height: 9px; + border: 2px solid var(--agent-working); + border-top-color: transparent; + border-radius: 50%; + content: ''; + animation: state-spinner 850ms linear infinite; +} + +.agent-state.finished { + color: var(--agent-done); +} + +.agent-state.finished .icon { + width: 13px; + height: 13px; +} + +.agent-finished[data-seen='false'] .agent-state { + filter: drop-shadow(0 0 4px color-mix(in srgb, var(--agent-done) 65%, transparent)); +} + +.agent-node.is-pinned .agent-avatar { + box-shadow: + 0 0 0 3px var(--background), + 0 0 0 5px var(--ring); +} + +.agent-node.is-pinned::after { + position: absolute; + z-index: 2; + right: -7px; + bottom: -2px; + padding: 1px 4px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--card); + color: var(--foreground); + content: 'PIN'; + font-size: 6px; + font-weight: 700; +} + +.agent-node:hover, +.agent-node:focus-visible { + z-index: 5; +} + +.agent-node:hover .agent-avatar, +.agent-node:focus-visible .agent-avatar { + transform: scale(1.08); + background: var(--accent); + box-shadow: + 0 0 0 3px var(--background), + 0 0 0 5px var(--ring); +} + +.agent-name { + position: absolute; + top: calc(100% + 7px); + left: 50%; + max-width: 132px; + padding: 2px 5px; + transform: translateX(-50%); + overflow: hidden; + border-radius: 4px; + background: color-mix(in srgb, var(--background) 86%, transparent); + font-size: 9px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-node small { + position: absolute; + top: -1px; + right: -7px; + min-width: 23px; + padding: 1px 4px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--card); + color: var(--muted-foreground); + font-size: 7px; + font-variant-numeric: tabular-nums; +} + +.done-cluster { + position: absolute; + z-index: 2; + top: var(--y); + left: var(--x); + display: none; + align-items: center; + gap: 5px; + padding: 5px 7px; + transform: translate(-50%, -50%); + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--card) 82%, transparent); + color: var(--muted-foreground); +} + +.done-cluster:hover, +.done-cluster:focus-visible { + border-color: var(--ring); + background: var(--accent); + color: var(--foreground); + outline: none; +} + +.done-cluster.is-visible { + display: flex; +} + +.done-cluster strong { + font-size: 8px; + font-weight: 600; + white-space: nowrap; +} + +.dot-stack { + position: relative; + width: 17px; + height: 11px; +} + +.dot-stack i { + position: absolute; + top: 2px; + width: 7px; + height: 7px; + border: 1px solid var(--background); + border-radius: 50%; + background: var(--agent-done); + opacity: 0.7; +} + +.dot-stack i:nth-child(1) { + left: 0; +} + +.dot-stack i:nth-child(2) { + left: 5px; +} + +.dot-stack i:nth-child(3) { + left: 10px; +} + +.empty-state.is-visible { + display: flex; +} + +.empty-state { + position: absolute; + z-index: 10; + top: 50%; + left: 50%; + display: none; + width: 310px; + transform: translate(-50%, -50%); + flex-direction: column; + align-items: center; + padding: 22px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--card); + text-align: center; +} + +.empty-state strong { + margin-top: 12px; + font-size: 13px; +} + +.empty-state p { + margin: 5px 0 14px; + color: var(--muted-foreground); + font-size: 11px; +} + +.empty-state button { + height: 30px; + padding: 0 10px; + border: 0; + border-radius: 7px; + background: var(--primary); + color: var(--primary-foreground); + font-size: 11px; +} + +.empty-orbit { + position: relative; + width: 42px; + height: 42px; + border: 1px solid var(--ring); + border-radius: 50%; +} + +.empty-orbit i { + position: absolute; + top: 50%; + left: 50%; + width: 8px; + height: 8px; + transform: translate(-50%, -50%); + border: 1px solid var(--foreground); + border-radius: 50%; +} + +.done-mode-note { + position: absolute; + bottom: 20px; + left: 50%; + display: none; + transform: translateX(-50%); + flex-direction: column; + align-items: center; + color: var(--muted-foreground); + text-align: center; +} + +.done-mode-note.is-visible { + display: flex; +} + +.done-mode-note strong { + color: var(--foreground); + font-size: 10px; +} + +.done-mode-note span { + font-size: 9px; +} + +.done-inspector { + position: absolute; + z-index: 15; + right: 18px; + bottom: 18px; + left: 18px; + display: none; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--popover); + box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); +} + +.done-inspector.is-open { + display: block; +} + +.done-inspector > header { + min-height: 48px; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} + +.done-inspector > header > div { + display: flex; + flex-direction: column; +} + +.done-inspector > header strong { + font-size: 12px; +} + +.done-inspector > header > span { + color: var(--muted-foreground); + font-size: 10px; +} + +.done-inspector > header button { + width: 26px; + height: 26px; + display: grid; + margin-left: auto; + place-items: center; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted-foreground); +} + +.done-inspector > header button:hover { + background: var(--accent); + color: var(--foreground); +} + +.done-agent-grid { + display: flex; + align-items: flex-start; + gap: 16px; + min-height: 78px; + padding: 13px 16px 9px; + overflow: hidden; +} + +.done-agent { + width: 52px; + flex: 0 0 auto; + border: 0; + background: transparent; + color: var(--muted-foreground); + text-align: center; +} + +.done-agent > span { + width: 30px; + height: 30px; + display: grid; + margin: 0 auto 5px; + place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--agent-done) 18%, var(--background)); + color: var(--agent-done); +} + +.done-agent > span .icon { + width: 13px; +} + +.done-agent small { + display: block; + overflow: hidden; + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.done-agent:hover { + color: var(--foreground); +} + +.done-inspector > footer { + padding: 7px 12px; + border-top: 1px solid var(--border); + color: var(--muted-foreground); + font-size: 9px; +} + +.zoom-controls { + position: absolute; + bottom: 13px; + left: 13px; + z-index: 5; + height: 33px; + display: flex; + align-items: center; + padding: 3px; + border: 1px solid var(--border); + border-radius: 9px; + background: color-mix(in srgb, var(--card) 88%, transparent); +} + +.zoom-controls button { + height: 25px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted-foreground); + font-size: 11px; +} + +.zoom-controls button:hover { + background: var(--accent); + color: var(--foreground); +} + +.zoom-controls span { + padding: 0 5px; + color: var(--muted-foreground); + font-size: 9px; +} + +.zoom-controls .icon { + width: 13px; +} + +.orchestrate { + position: absolute; + right: 22px; + bottom: 18px; + z-index: 20; + height: 42px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 17px; + border: 0; + border-radius: 999px; + background: var(--primary); + color: var(--primary-foreground); + box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); + font-weight: 600; +} + +.orchestrate i { + position: absolute; + top: 5px; + left: 31px; + width: 6px; + height: 6px; + border: 1px solid var(--primary); + border-radius: 50%; + background: var(--status-success); +} + +.terminal-overlay { + position: fixed; + z-index: 100; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + background: rgb(0 0 0 / 0.56); +} + +.terminal-overlay.is-open { + display: flex; +} + +.terminal-dialog { + width: calc(100vw - 40px); + max-width: none; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--popover); + box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); +} + +.terminal-dialog > header { + height: 38px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 10px; +} + +.terminal-agent-icon { + width: 20px; + height: 20px; + display: grid; + place-items: center; + border-radius: 5px; + background: var(--muted); +} + +.terminal-agent-icon .icon { + width: 12px; +} + +.terminal-dialog > header strong { + font-size: 12px; +} + +.terminal-dialog > header > span:not(.terminal-agent-icon) { + color: var(--muted-foreground); + font-size: 11px; +} + +.terminal-dialog > header button { + width: 26px; + height: 26px; + display: grid; + margin-left: auto; + place-items: center; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted-foreground); +} + +.terminal-dialog > header button:hover { + background: var(--accent); + color: var(--foreground); +} + +.terminal-dialog pre { + height: min(61vh, 560px); + margin: 0; + padding: 20px; + overflow: auto; + background: var(--editor-surface); + color: var(--foreground); + font: + 12px/1.65 'SF Mono', + SFMono-Regular, + ui-monospace, + Menlo, + Consolas, + monospace; +} + +.terminal-dialog pre .prompt { + color: var(--muted-foreground); +} + +.terminal-dialog pre .muted { + color: var(--muted-foreground); +} + +.terminal-dialog pre .success { + color: var(--status-success); +} + +.terminal-dialog pre .cursor { + animation: cursor-blink 1s step-end infinite; +} + +.terminal-compose { + min-height: 40px; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border-top: 1px solid var(--border); + background: var(--editor-surface); + color: var(--muted-foreground); + font: + 11px/1.4 'SF Mono', + SFMono-Regular, + ui-monospace, + Menlo, + Consolas, + monospace; +} + +.terminal-compose:focus-within { + box-shadow: inset 0 0 0 1px var(--ring); +} + +.terminal-compose input { + min-width: 0; + height: 28px; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--foreground); + font: inherit; +} + +.terminal-compose input::placeholder { + color: var(--muted-foreground); +} + +.terminal-dialog > footer { + height: 42px; + display: flex; + align-items: center; + gap: 6px; + justify-content: flex-end; + padding: 0 10px; +} + +.terminal-dialog > footer > span { + margin-right: auto; + color: var(--muted-foreground); + font-size: 9px; +} + +.terminal-action, +.open-worktree { + height: 28px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 1px solid var(--border); + border-radius: 7px; + background: transparent; + font-size: 11px; +} + +.terminal-action:hover, +.open-worktree:hover { + background: var(--accent); +} + +.terminal-action .icon, +.open-worktree .icon { + width: 13px; +} + +.terminal-action[aria-pressed='true'] { + background: var(--accent); + color: var(--accent-foreground); +} + +.terminal-action[hidden] { + display: none; +} + +@keyframes state-spinner { + to { + transform: rotate(360deg); + } +} + +@keyframes cursor-blink { + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .terminal-dialog pre .cursor { + animation: none; + } + + .state-mark.working, + .agent-state.working::before { + border-top-color: var(--agent-working); + animation: none; + } +} + +@media (max-width: 1280px) { + .project-primary { + left: 37%; + } + + .project-secondary { + right: 2%; + } + + .agent-name { + max-width: 96px; + } +} diff --git a/agent-fleet-refined/agent-rings-focus.html b/agent-fleet-refined/agent-rings-focus.html new file mode 100644 index 00000000000..15d450d0308 --- /dev/null +++ b/agent-fleet-refined/agent-rings-focus.html @@ -0,0 +1,660 @@ + + + + + + Orca Agent Rings — Focused Fleet + + + + + + +
+
+ +

Orca Agent Dashboard

+
+ +
+
+ Agents + 196 total +
+ +
+ +
+ + +
+ +
+ + +
+
+
+ Live containment map + Focus · 15 of 196 agents +
+ +
+ + + +
+
+ +
+
+ + No agents need attention +

Turn on Working or Finished, or broaden the finished-result window.

+ +
+ +
+
+ orca + 6 working · 6 new results · 151 earlier +
+ +
+
+ albacore + 2 working · 1 finished +
+ + + + +
+ +
+
+ orca-trust-dialog + 1 working · 2 finished +
+ + + + +
+ +
+
+ dashboard-popout + 1 working · 1 finished +
+ + + +
+ +
+
+ terminal-recovery + 1 working · 1 finished +
+ + + +
+ +
+
+ git-compat + 1 working · 1 finished +
+ + + +
+
+ +
+
+ orca · remote + 1 working · 2 new results · 30 earlier +
+
+
+ relay-session + 1 working · 2 finished +
+ + + + +
+
+ +
+ New results stay visible; earlier results are summarized + Choose an agent to continue it, or a summary to browse older results. +
+ +
+
+
+ Earlier results + albacore +
+ 64 agents + +
+
+
Select an agent to reopen its terminal conversation
+
+ +
+ + 100% + + +
+
+
+
+ + +
+ + + + + + diff --git a/agent-fleet-refined/agent-rings-focus.js b/agent-fleet-refined/agent-rings-focus.js new file mode 100644 index 00000000000..87477410c15 --- /dev/null +++ b/agent-fleet-refined/agent-rings-focus.js @@ -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' + ? '' + : '' +} + +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 = ` + + Agent ${String(index + 1).padStart(2, '0')} + ` + 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() diff --git a/agent-fleet-refined/agent-rings-focus.png b/agent-fleet-refined/agent-rings-focus.png new file mode 100644 index 00000000000..740c8677652 Binary files /dev/null and b/agent-fleet-refined/agent-rings-focus.png differ diff --git a/agent-fleet-refined/command-deck.png b/agent-fleet-refined/command-deck.png new file mode 100644 index 00000000000..5deb5573799 Binary files /dev/null and b/agent-fleet-refined/command-deck.png differ diff --git a/agent-fleet-refined/fleet-command-center.css b/agent-fleet-refined/fleet-command-center.css new file mode 100644 index 00000000000..1d0f5261af9 --- /dev/null +++ b/agent-fleet-refined/fleet-command-center.css @@ -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; } +} diff --git a/agent-fleet-refined/fleet-command-center.html b/agent-fleet-refined/fleet-command-center.html new file mode 100644 index 00000000000..6f7e7292b06 --- /dev/null +++ b/agent-fleet-refined/fleet-command-center.html @@ -0,0 +1,157 @@ + + + + + + Orca Fleet Command Center + + + + + + +
+
+
O
+
AgentsFleet command
+
168accounted for
+ + + +
+ +
+ + +
+
+
Live fleet topologyEverything running, one field of view
+
+ Needs you 9 + Working 34 + Tracking 71 + Recent 54 +
+
+
+
ORCA · 112
+
MOBILE · 39
+
RELAY + OTHER · 17
+
INTERVENE
+
ACTIVE
+
TRACKING
+
RECENT
+
+
+ +
+ + + + + +
Fleet
+
+
+
+ LIVE +

Test verification finishedreview-11464 · 418 passed

+

Agent resumedmobile-9717

+

Review convergedpr-9190

+
+
+ + +
+
+ + +
Three agents unblockedAdvancing to the next intervention…
+ + + diff --git a/agent-fleet-refined/fleet-command-center.js b/agent-fleet-refined/fleet-command-center.js new file mode 100644 index 00000000000..037f6bf3a4b --- /dev/null +++ b/agent-fleet-refined/fleet-command-center.js @@ -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') + }) +}) diff --git a/agent-fleet-refined/fleet-command-center.png b/agent-fleet-refined/fleet-command-center.png new file mode 100644 index 00000000000..6ad1a7e3eb1 Binary files /dev/null and b/agent-fleet-refined/fleet-command-center.png differ diff --git a/agent-fleet-refined/fleet-command-terminal.png b/agent-fleet-refined/fleet-command-terminal.png new file mode 100644 index 00000000000..86d80f1fb14 Binary files /dev/null and b/agent-fleet-refined/fleet-command-terminal.png differ diff --git a/agent-fleet-refined/fleet-rings-hover.png b/agent-fleet-refined/fleet-rings-hover.png new file mode 100644 index 00000000000..d35ec93c910 Binary files /dev/null and b/agent-fleet-refined/fleet-rings-hover.png differ diff --git a/agent-fleet-refined/fleet-rings.css b/agent-fleet-refined/fleet-rings.css new file mode 100644 index 00000000000..5af6b53246a --- /dev/null +++ b/agent-fleet-refined/fleet-rings.css @@ -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; } +} diff --git a/agent-fleet-refined/fleet-rings.html b/agent-fleet-refined/fleet-rings.html new file mode 100644 index 00000000000..afaeba765a4 --- /dev/null +++ b/agent-fleet-refined/fleet-rings.html @@ -0,0 +1,177 @@ + + + + + + Orca Fleet Rings + + + + + + +
+
+
O
+
AgentsFleet rings
+
168accounted for
+ + + +
+ +
+ + +
+
+
Live containment mapOwnership, activity, and time in one canvas
+
+
+
+ + + + ORCA112 AGENTS · 58 WORKTREES + + + + MOBILE39 AGENTS · 13 WORKTREES + + + + RELAY + OTHER17 AGENTS · 7 WORKTREES + + + + + 47 WORKTREES74 AGENTS + + + PR-8783 REVIEW13 AGENTS + + + PR-9190 REVIEW12 AGENTS + + + REVIEW-11464 + + + TRUST DIALOG + + + CODEX RESUME + + + + MOBILE-971712 AGENTS + + + SAVE HOST + + + IME-6995 + + + + 9 WORKTREES16 AGENTS + + + + CONNECTION + + + DOCS + TOOLING + + + +
+
Cross-platform reviewerpr-8783-internal-review · working 42m
hover
+
› Running the restart matrix across native,
+WSL, and SSH hosts…
+
+418 tests passed
+Waiting on cleanup retention policy.
+
Click agent to pin terminallive
+
+
Fleet
+
+
+
+ + +
+
+ + +
Direction sent to 3 agentsThe worktree ring is unblocked.
+ + + diff --git a/agent-fleet-refined/fleet-rings.js b/agent-fleet-refined/fleet-rings.js new file mode 100644 index 00000000000..2fde9377d96 --- /dev/null +++ b/agent-fleet-refined/fleet-rings.js @@ -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) +}) diff --git a/agent-fleet-refined/fleet-rings.png b/agent-fleet-refined/fleet-rings.png new file mode 100644 index 00000000000..88edd9fa2ac Binary files /dev/null and b/agent-fleet-refined/fleet-rings.png differ diff --git a/agent-fleet-refined/terminal-popover.png b/agent-fleet-refined/terminal-popover.png new file mode 100644 index 00000000000..ea612c16678 Binary files /dev/null and b/agent-fleet-refined/terminal-popover.png differ diff --git a/agent-viewer-alternatives/.gitignore b/agent-viewer-alternatives/.gitignore new file mode 100644 index 00000000000..9cc792b5cca --- /dev/null +++ b/agent-viewer-alternatives/.gitignore @@ -0,0 +1,3 @@ +actual-agent-activity.js +actual-agent-activity.json +activity-lanes-actual-dark.png diff --git a/agent-viewer-alternatives/README.md b/agent-viewer-alternatives/README.md new file mode 100644 index 00000000000..441d396c185 --- /dev/null +++ b/agent-viewer-alternatives/README.md @@ -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 diff --git a/agent-viewer-alternatives/activity-lanes-dark.png b/agent-viewer-alternatives/activity-lanes-dark.png new file mode 100644 index 00000000000..021684bd82d Binary files /dev/null and b/agent-viewer-alternatives/activity-lanes-dark.png differ diff --git a/agent-viewer-alternatives/app.js b/agent-viewer-alternatives/app.js new file mode 100644 index 00000000000..111a5f6f804 --- /dev/null +++ b/agent-viewer-alternatives/app.js @@ -0,0 +1,688 @@ +const activitySnapshot = window.AGENT_ACTIVITY_SNAPSHOT ?? { + capturedAt: Date.now(), + sourceSummary: { worktrees: 0, agents: 0, working: 0, done: 0, parentLinked: 0 }, + agents: [] +} + +function formatAge(ageMinutes) { + if (ageMinutes < 1) return `${Math.max(1, Math.round(ageMinutes * 60))}s` + if (ageMinutes < 60) return `${Math.floor(ageMinutes)}m` + if (ageMinutes < 1440) return `${Math.floor(ageMinutes / 60)}h` + return `${Math.floor(ageMinutes / 1440)}d` +} + +function formatDuration(durationMs) { + const minutes = Math.max(1, Math.round(durationMs / 60000)) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + const remainder = minutes % 60 + if (hours < 24) return remainder ? `${hours}h ${remainder}m` : `${hours}h` + const days = Math.floor(hours / 24) + return `${days}d ${hours % 24}h` +} + +const agents = activitySnapshot.agents.map((agent) => { + const ageMinutes = Math.max(0, (activitySnapshot.capturedAt - agent.updatedAt) / 60000) + const age = formatAge(ageMinutes) + const status = agent.status === 'waiting' ? 'attention' : agent.status + const endedAt = + status === 'done' ? (agent.completedAt ?? agent.updatedAt) : activitySnapshot.capturedAt + const startedAt = Math.min(agent.startedAt ?? endedAt, endedAt) + return { + ...agent, + status, + startedAt, + endedAt, + duration: formatDuration(endedAt - startedAt), + bucket: ['attention', 'blocked'].includes(status) ? 'attention' : status, + ageMinutes, + age, + older: status === 'done' && ageMinutes > 120, + response: + status === 'done' + ? `Finished ${age} ago. Open the session to review its result or send a follow-up.` + : `${agent.detail}. Last activity ${age} ago.`, + terminal: + 'Metadata-only prototype.\n\nThe app would reuse the live Dashboard terminal preview in this panel.', + files: [] + } +}) + +const conceptMeta = { + lineage: { + kicker: 'Recommended companion view', + title: 'Lineage atlas', + description: + 'Worktree branches frame parent-and-child agent chains without changing icon size.', + tradeoff: 'Best for tracing handoffs; slower when the only question is who replied last.' + }, + lanes: { + kicker: 'Current-data prototype', + title: 'Activity lanes', + description: + 'Each agent runs from its session start to completion or Now; children stay with their parent.', + tradeoff: + 'Active sessions always stay visible; completed sessions older than two hours collapse by default.' + } +} + +const statusLabels = { + attention: 'Needs you', + working: 'Working', + done: 'Done', + blocked: 'Blocked', + idle: 'Idle' +} + +const state = { + concept: window.location.hash.slice(1) in conceptMeta ? window.location.hash.slice(1) : 'lanes', + selectedAgentId: null, + recipients: [], + showOlder: false +} + +const elements = { + root: document.documentElement, + tabs: [...document.querySelectorAll('[data-concept]')], + panels: [...document.querySelectorAll('[data-panel]')], + title: document.querySelector('#concept-title'), + kicker: document.querySelector('#concept-kicker'), + description: document.querySelector('#concept-description'), + tradeoff: document.querySelector('#concept-tradeoff span'), + stage: document.querySelector('#concept-stage'), + viewer: document.querySelector('#viewer-layout'), + agentPanel: document.querySelector('#agent-panel'), + search: document.querySelector('#search'), + recent: document.querySelector('#recent-filter'), + status: document.querySelector('#status-filter'), + project: document.querySelector('#project-filter'), + worktree: document.querySelector('#worktree-filter'), + result: document.querySelector('#filter-result'), + older: document.querySelector('#older-toggle'), + theme: document.querySelector('#theme-toggle'), + talkDrop: document.querySelector('#talk-drop'), + recipientList: document.querySelector('#recipient-list'), + talkMessage: document.querySelector('#talk-message'), + talkForm: document.querySelector('#talk-form'), + talkSend: document.querySelector('.talk-send'), + talkNote: document.querySelector('#talk-note'), + toast: document.querySelector('#toast') +} + +function icon(name) { + return `` +} + +function statusIcon(agent) { + return `${icon('bot')}` +} + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function selectedClass(agent) { + return agent.id === state.selectedAgentId ? ' is-selected' : '' +} + +function filterAgents(includeOlder = state.showOlder) { + const query = elements.search.value.trim().toLowerCase() + const recentMinutes = Number(elements.recent.value) + return agents.filter((agent) => { + const matchesQuery = + !query || + [agent.name, agent.project, agent.worktree, agent.response].some((value) => + value.toLowerCase().includes(query) + ) + const matchesRecent = agent.status !== 'done' || agent.ageMinutes <= recentMinutes + const matchesStatus = + elements.status.value === 'all' || + agent.status === elements.status.value || + (elements.status.value === 'attention' && agent.bucket === 'attention') + const matchesProject = + elements.project.value === 'all' || agent.project === elements.project.value + const matchesWorktree = + elements.worktree.value === 'all' || agent.worktree === elements.worktree.value + const matchesOlder = includeOlder || !agent.older + return ( + matchesQuery && + matchesRecent && + matchesStatus && + matchesProject && + matchesWorktree && + matchesOlder + ) + }) +} + +function newestAge(group) { + return [...group].sort((a, b) => a.ageMinutes - b.ageMinutes)[0]?.age ?? '—' +} + +function treeAgent(agent, isChild = false) { + return ` +
+ + +
+ ` +} + +function renderAgentTree(worktreeAgents) { + const ids = new Set(worktreeAgents.map((agent) => agent.id)) + const roots = worktreeAgents.filter((agent) => !agent.parent || !ids.has(agent.parent)) + return roots + .map((root) => { + const children = worktreeAgents.filter((agent) => agent.parent === root.id) + return ` +
+ ${treeAgent(root)} + ${children.length ? `
${children.map((child) => treeAgent(child, true)).join('')}
` : ''} +
+ ` + }) + .join('') +} + +function renderLineage(visibleAgents) { + const projects = [...new Set(visibleAgents.map((agent) => agent.project))] + return ` +
+ ${projects + .map((project) => { + const projectAgents = visibleAgents.filter((agent) => agent.project === project) + const worktrees = [...new Set(projectAgents.map((agent) => agent.worktree))] + return ` +
+
+

${escapeHtml(project)}

+ Project lineage + +
+
+ ${worktrees + .map((worktree) => { + const worktreeAgents = projectAgents.filter( + (agent) => agent.worktree === worktree + ) + const branchFrom = worktreeAgents.find((agent) => agent.branchFrom)?.branchFrom + return ` +
+
+ ${icon('branch')} + + ${escapeHtml(worktree)} + ${branchFrom ? `from ${escapeHtml(branchFrom)}` : 'main worktree'} + + +
+ ${renderAgentTree(worktreeAgents)} +
+ ` + }) + .join('')} +
+
+ ` + }) + .join('')} +
+ ` +} + +const timelineRowHeight = 52 +const timelineLineY = 37 + +function timelineWindow() { + const minutes = Number(elements.recent.value) + return { + minutes, + start: activitySnapshot.capturedAt - minutes * 60000, + end: activitySnapshot.capturedAt + } +} + +function timelinePercent(timestamp, window) { + const clamped = Math.min(window.end, Math.max(window.start, timestamp)) + return ((clamped - window.start) / (window.end - window.start)) * 100 +} + +function timelineBounds(agent, window) { + const start = timelinePercent(agent.startedAt, window) + const end = timelinePercent(agent.endedAt, window) + const lineStart = end - start < 0.6 ? Math.max(0, end - 0.6) : start + return { + start, + end, + lineStart, + width: Math.max(0.6, end - lineStart), + response: timelinePercent(agent.updatedAt, window), + clippedStart: agent.startedAt < window.start + } +} + +function formatTimelineTick(timestamp, window, isNow) { + if (isNow) return 'Now' + const options = + window.minutes >= 1440 + ? { weekday: 'short', hour: 'numeric' } + : { hour: 'numeric', minute: '2-digit' } + return new Date(timestamp).toLocaleString([], options) +} + +function orderLaneAgents(laneAgents) { + const byId = new Map(laneAgents.map((agent) => [agent.id, agent])) + const children = new Map() + laneAgents.forEach((agent) => { + if (!agent.parent || !byId.has(agent.parent)) return + children.set(agent.parent, [...(children.get(agent.parent) ?? []), agent]) + }) + const byStart = (a, b) => a.startedAt - b.startedAt + const ordered = [] + const visited = new Set() + function visit(agent) { + if (visited.has(agent.id)) return + visited.add(agent.id) + ordered.push(agent) + ;(children.get(agent.id) ?? []).sort(byStart).forEach(visit) + } + laneAgents + .filter((agent) => !agent.parent || !byId.has(agent.parent)) + .sort(byStart) + .forEach(visit) + laneAgents.sort(byStart).forEach(visit) + return ordered +} + +function timelineLaneKey(agent, agentsById) { + const visited = new Set([agent.id]) + let root = agent + while (root.parent && agentsById.has(root.parent) && !visited.has(root.parent)) { + visited.add(root.parent) + root = agentsById.get(root.parent) + } + return root.worktree +} + +function renderLaneConnections(laneAgents, boundsById) { + const indexById = new Map(laneAgents.map((agent, index) => [agent.id, index])) + const paths = laneAgents + .filter((agent) => indexById.has(agent.parent)) + .map((agent) => { + const x = boundsById.get(agent.id).start + const parentY = indexById.get(agent.parent) * timelineRowHeight + timelineLineY + const childY = indexById.get(agent.id) * timelineRowHeight + timelineLineY + return `` + }) + .join('') + if (!paths) return '' + return ` + + ` +} + +function renderTimelineAgent(agent, laneIds, laneWorktree, bounds) { + const responseTick = + bounds.response > bounds.start + 1 && bounds.response < bounds.end - 1 + ? `` + : '' + const isChild = agent.parent && laneIds.has(agent.parent) + const labelSide = bounds.start > 72 ? ' is-label-left' : '' + const clipped = bounds.clippedStart ? ' is-clipped-start' : '' + const childLabel = isChild + ? agent.worktree === laneWorktree + ? ' · child agent' + : ` · child · ${escapeHtml(agent.worktree)}` + : '' + return ` + + ` +} + +function renderLanes(visibleAgents) { + const window = timelineWindow() + const ticks = Array.from( + { length: 5 }, + (_, index) => window.start + ((window.end - window.start) * index) / 4 + ) + const agentsById = new Map(visibleAgents.map((agent) => [agent.id, agent])) + const laneKeyById = new Map( + visibleAgents.map((agent) => [agent.id, timelineLaneKey(agent, agentsById)]) + ) + const worktrees = [...new Set(laneKeyById.values())].sort((a, b) => { + const laneAge = (worktree) => + Math.min( + ...visibleAgents + .filter((agent) => laneKeyById.get(agent.id) === worktree) + .map((agent) => agent.ageMinutes) + ) + return laneAge(a) - laneAge(b) + }) + return ` +
+
+ Worktree · session duration +
${ticks.map((tick, index) => `${formatTimelineTick(tick, window, index === ticks.length - 1)}`).join('')}
+
+ ${worktrees + .map((worktree) => { + const laneAgents = orderLaneAgents( + visibleAgents.filter((agent) => laneKeyById.get(agent.id) === worktree) + ) + const laneIds = new Set(laneAgents.map((agent) => agent.id)) + const boundsById = new Map( + laneAgents.map((agent) => [agent.id, timelineBounds(agent, window)]) + ) + return ` +
+
+ ${escapeHtml(worktree)} + ${escapeHtml(laneAgents[0].project)} + +
+
+
+ ${renderLaneConnections(laneAgents, boundsById)} + ${laneAgents.map((agent) => renderTimelineAgent(agent, laneIds, worktree, boundsById.get(agent.id))).join('')} +
+
+
+ ` + }) + .join('')} +
+ ` +} + +function renderEmpty() { + return `
${icon('search')}No matching agentsBroaden a filter or show older completed sessions.
` +} + +function bindAgentInteractions() { + document.querySelectorAll('[data-agent-id]').forEach((trigger) => { + trigger.addEventListener('click', () => openAgent(trigger.dataset.agentId)) + trigger.addEventListener('dragstart', (event) => { + event.dataTransfer.setData('text/plain', trigger.dataset.agentId) + event.dataTransfer.effectAllowed = 'copy' + }) + }) + document.querySelectorAll('[data-add-recipient]').forEach((button) => { + button.addEventListener('click', () => addRecipient(button.dataset.addRecipient)) + }) +} + +function render() { + const visibleAgents = filterAgents() + const hiddenOlder = filterAgents(true).filter((agent) => agent.older).length + const activePanel = document.querySelector(`[data-panel="${state.concept}"]`) + const renderers = { lineage: renderLineage, lanes: renderLanes } + activePanel.innerHTML = visibleAgents.length + ? renderers[state.concept](visibleAgents) + : renderEmpty() + const summary = activitySnapshot.sourceSummary + const capturedAt = new Date(activitySnapshot.capturedAt).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit' + }) + elements.result.textContent = `${visibleAgents.length} shown · ${ + state.showOlder ? 'older results visible' : `${hiddenOlder} older results hidden` + } · ${summary.agents} agents / ${summary.worktrees} worktrees · captured ${capturedAt}` + bindAgentInteractions() +} + +function switchConcept(concept) { + state.concept = concept + const meta = conceptMeta[concept] + elements.tabs.forEach((tab) => { + const active = tab.dataset.concept === concept + tab.classList.toggle('is-active', active) + tab.setAttribute('aria-selected', String(active)) + }) + elements.panels.forEach((panel) => { + const active = panel.dataset.panel === concept + panel.classList.toggle('is-active', active) + panel.hidden = !active + }) + elements.kicker.textContent = meta.kicker + elements.title.textContent = meta.title + elements.description.textContent = meta.description + elements.tradeoff.textContent = meta.tradeoff + if (window.location.hash.slice(1) !== concept) window.location.hash = concept + render() +} + +function findAgent(id) { + return agents.find((agent) => agent.id === id) +} + +function openAgent(id) { + const agent = findAgent(id) + if (!agent) return + state.selectedAgentId = id + elements.viewer.classList.add('has-panel') + elements.agentPanel.hidden = false + document.querySelector('#agent-panel-title').textContent = agent.name + document.querySelector('#agent-panel-meta').textContent = `${agent.project} · ${agent.worktree}` + const panelStatus = document.querySelector('#panel-status') + panelStatus.className = agent.status + panelStatus.textContent = statusLabels[agent.status] + document.querySelector('#panel-age').textContent = `${agent.age} since response` + document.querySelector('#panel-response').textContent = agent.response + document.querySelector('#panel-terminal').textContent = agent.terminal + const panelFiles = document.querySelector('.panel-files') + panelFiles.hidden = agent.files.length === 0 + document.querySelector('#panel-files').innerHTML = agent.files + .map((file) => `
  • ${escapeHtml(file)}
  • `) + .join('') + document.querySelector('#panel-add-talk').dataset.agentId = id + render() + requestAnimationFrame(() => { + document.querySelector(`[data-agent-id="${id}"]`)?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' + }) + }) +} + +function closeAgent() { + state.selectedAgentId = null + elements.viewer.classList.remove('has-panel') + elements.agentPanel.hidden = true + render() +} + +function addRecipient(id) { + const agent = findAgent(id) + if (!agent || state.recipients.includes(id)) return + state.recipients.push(id) + renderRecipients() + elements.talkNote.textContent = `${agent.name} added. Nothing has been sent.` +} + +function removeRecipient(id) { + state.recipients = state.recipients.filter((recipient) => recipient !== id) + renderRecipients() +} + +function renderRecipients() { + elements.recipientList.innerHTML = state.recipients + .map((id) => { + const agent = findAgent(id) + return ` + + ${escapeHtml(agent.name)} + + + ` + }) + .join('') + elements.recipientList.querySelectorAll('[data-remove-recipient]').forEach((button) => { + button.addEventListener('click', () => removeRecipient(button.dataset.removeRecipient)) + }) + elements.talkSend.disabled = state.recipients.length === 0 || !elements.talkMessage.value.trim() +} + +let toastTimer +function showToast(message) { + clearTimeout(toastTimer) + elements.toast.textContent = message + elements.toast.classList.add('is-visible') + toastTimer = setTimeout(() => elements.toast.classList.remove('is-visible'), 2600) +} + +function populateFilters() { + const projects = [...new Set(agents.map((agent) => agent.project))].sort() + const worktrees = [...new Set(agents.map((agent) => agent.worktree))].sort() + elements.project.insertAdjacentHTML( + 'beforeend', + projects + .map((project) => ``) + .join('') + ) + elements.worktree.insertAdjacentHTML( + 'beforeend', + worktrees + .map((worktree) => ``) + .join('') + ) +} + +elements.tabs.forEach((tab, index) => { + tab.addEventListener('click', () => switchConcept(tab.dataset.concept)) + tab.addEventListener('keydown', (event) => { + if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return + event.preventDefault() + const direction = event.key === 'ArrowRight' ? 1 : -1 + const next = elements.tabs[(index + direction + elements.tabs.length) % elements.tabs.length] + next.focus() + switchConcept(next.dataset.concept) + }) +}) + +window.addEventListener('hashchange', () => { + const concept = window.location.hash.slice(1) + if (concept in conceptMeta && concept !== state.concept) switchConcept(concept) +}) + +;[elements.search, elements.recent, elements.status, elements.project, elements.worktree].forEach( + (control) => control.addEventListener('input', render) +) +;[elements.recent, elements.status, elements.project, elements.worktree].forEach((control) => + control.addEventListener('change', render) +) + +elements.older.addEventListener('click', () => { + state.showOlder = !state.showOlder + elements.older.setAttribute('aria-pressed', String(state.showOlder)) + elements.older.querySelector('span').textContent = state.showOlder ? 'Hide older' : 'Show older' + render() +}) + +elements.theme.addEventListener('click', () => { + const dark = elements.root.classList.toggle('dark') + elements.theme.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme') + localStorage.setItem('orca-agent-viewer-theme', dark ? 'dark' : 'light') +}) + +document.querySelector('#close-panel').addEventListener('click', closeAgent) +document.querySelector('#panel-add-talk').addEventListener('click', (event) => { + addRecipient(event.currentTarget.dataset.agentId) +}) +document.querySelector('#panel-open-worktree').addEventListener('click', () => { + const agent = findAgent(state.selectedAgentId) + showToast(`${agent.worktree} would open in Orca.`) +}) + +elements.talkDrop.addEventListener('dragover', (event) => { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + elements.talkDrop.classList.add('is-drag-over') +}) +elements.talkDrop.addEventListener('dragleave', () => { + elements.talkDrop.classList.remove('is-drag-over') +}) +elements.talkDrop.addEventListener('drop', (event) => { + event.preventDefault() + elements.talkDrop.classList.remove('is-drag-over') + addRecipient(event.dataTransfer.getData('text/plain')) +}) + +elements.talkMessage.addEventListener('input', renderRecipients) +elements.talkForm.addEventListener('submit', (event) => { + event.preventDefault() + if (elements.talkSend.disabled) return + const recipientNames = state.recipients.map((id) => findAgent(id).name) + showToast(`Follow-up sent to ${recipientNames.join(', ')}.`) + elements.talkNote.textContent = `Sent to ${recipientNames.join(', ')}.` + state.recipients = [] + elements.talkMessage.value = '' + renderRecipients() +}) + +document.addEventListener('keydown', (event) => { + const isMac = navigator.userAgent.includes('Mac') + if (event.key.toLowerCase() === 'k' && (isMac ? event.metaKey : event.ctrlKey)) { + event.preventDefault() + elements.search.focus() + } + if (event.key === 'Escape' && !elements.agentPanel.hidden) closeAgent() +}) + +const savedTheme = localStorage.getItem('orca-agent-viewer-theme') +if (savedTheme) elements.root.classList.toggle('dark', savedTheme === 'dark') +elements.theme.setAttribute( + 'aria-label', + elements.root.classList.contains('dark') ? 'Switch to light theme' : 'Switch to dark theme' +) +const isMac = navigator.userAgent.includes('Mac') +document.querySelector('#search-shortcut').textContent = isMac ? '⌘ K' : 'Ctrl+K' +populateFilters() +renderRecipients() +switchConcept(state.concept) diff --git a/agent-viewer-alternatives/capture-agent-activity-snapshot.mjs b/agent-viewer-alternatives/capture-agent-activity-snapshot.mjs new file mode 100644 index 00000000000..422d981016f --- /dev/null +++ b/agent-viewer-alternatives/capture-agent-activity-snapshot.mjs @@ -0,0 +1,205 @@ +import { execFileSync } from 'node:child_process' +import { + closeSync, + existsSync, + openSync, + readFileSync, + readSync, + readdirSync, + writeFileSync +} from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const directory = dirname(fileURLToPath(import.meta.url)) +const cli = + process.env.ORCA_CLI_COMMAND || + (process.env.ORCA_DEV_REPO_ROOT ? 'orca-dev' : process.platform === 'linux' ? 'orca-ide' : 'orca') +const payload = JSON.parse(execFileSync(cli, ['worktree', 'ps', '--json'], { encoding: 'utf8' })) + +if (!payload.ok || !Array.isArray(payload.result?.worktrees)) { + throw new Error('Orca did not return a worktree snapshot') +} + +const capturedAt = Date.now() +const worktrees = payload.result.worktrees +const allAgents = worktrees.flatMap((worktree) => worktree.agents ?? []) + +function userDataPath(appName) { + if (process.env.ORCA_AGENT_ACTIVITY_USER_DATA) { + return process.env.ORCA_AGENT_ACTIVITY_USER_DATA + } + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', appName) + } + if (process.platform === 'win32') { + return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), appName) + } + return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), appName) +} + +function readJson(path) { + if (!existsSync(path)) return null + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch { + return null + } +} + +function profileData(root) { + const profileIndex = readJson(join(root, 'orca-profile-index.json')) + const activeProfileId = profileIndex?.activeProfileId ?? 'local-default' + return ( + readJson(join(root, 'profiles', activeProfileId, 'orca-data.json')) ?? + readJson(join(root, 'orca-data.json')) + ) +} + +function collectTerminalStarts(data) { + const sessions = [data?.workspaceSession, ...Object.values(data?.workspaceSessionsByHostId ?? {})] + const starts = new Map() + for (const session of sessions) { + for (const tabs of Object.values(session?.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (!Number.isFinite(tab.createdAt)) continue + const previous = starts.get(tab.id) + if (previous === undefined || tab.createdAt > previous) starts.set(tab.id, tab.createdAt) + } + } + } + return starts +} + +function lastStatusPaths(root) { + const hooksDirectory = join(root, 'agent-hooks') + if (!existsSync(hooksDirectory)) return [] + const direct = join(hooksDirectory, 'last-status.json') + const nested = readdirSync(hooksDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(hooksDirectory, entry.name, 'last-status.json')) + return [direct, ...nested].filter(existsSync) +} + +function collectLastStatuses(roots) { + const statuses = new Map() + for (const path of new Set(roots.flatMap(lastStatusPaths))) { + const file = readJson(path) + for (const [paneKey, entry] of Object.entries(file?.entries ?? {})) { + const previous = statuses.get(paneKey) + if (!previous || (entry.receivedAt ?? 0) > (previous.receivedAt ?? 0)) { + statuses.set(paneKey, entry) + } + } + } + return statuses +} + +function firstLine(path) { + if (!path || !existsSync(path)) return null + const descriptor = openSync(path, 'r') + try { + const buffer = Buffer.alloc(64 * 1024) + const length = readSync(descriptor, buffer, 0, buffer.length, 0) + return buffer.subarray(0, length).toString('utf8').split('\n', 1)[0] + } finally { + closeSync(descriptor) + } +} + +function sessionStartedAt(status) { + try { + const line = firstLine(status?.providerSession?.transcriptPath) + if (!line) return null + const timestamp = Date.parse(JSON.parse(line).timestamp) + return Number.isFinite(timestamp) ? timestamp : null + } catch { + return null + } +} + +const appName = cli.includes('dev') ? 'orca-dev' : 'orca' +const dataRoot = userDataPath(appName) +const terminalStarts = collectTerminalStarts(profileData(dataRoot)) +const statusRoots = appName === 'orca-dev' ? [dataRoot, userDataPath('orca')] : [dataRoot] +const lastStatuses = collectLastStatuses(statusRoots) + +function truncate(value, length = 72) { + return value.length <= length ? value : `${value.slice(0, length - 1)}…` +} + +function sessionName(agent) { + if (agent.displayName?.trim()) return truncate(agent.displayName.trim()) + if (agent.taskTitle?.trim()) return truncate(agent.taskTitle.trim()) + const prompt = agent.prompt?.trim() ?? '' + const taskMarker = prompt.indexOf('=== TASK ===') + const task = taskMarker >= 0 ? prompt.slice(taskMarker + 12).trim() : prompt + const firstLine = task + .split('\n') + .find((line) => line.trim()) + ?.trim() + if (firstLine && !firstLine.startsWith('You are working inside Orca')) return truncate(firstLine) + return agent.agentType?.toUpperCase() ?? 'AGENT' +} + +const capturedAgents = worktrees.flatMap((worktree) => + (worktree.agents ?? []) + .filter( + (agent) => + agent.state !== 'done' || capturedAt - (agent.updatedAt ?? 0) <= 24 * 60 * 60 * 1000 + ) + .map((agent) => { + const tabId = agent.paneKey.split(':', 1)[0] + const status = lastStatuses.get(agent.paneKey) + const transcriptStart = sessionStartedAt(status) + const terminalStart = terminalStarts.get(tabId) + const startedAt = transcriptStart ?? terminalStart ?? agent.stateStartedAt ?? agent.updatedAt + return { + id: agent.paneKey, + name: sessionName(agent), + agentType: agent.agentType ?? 'agent', + project: worktree.repo, + worktree: worktree.displayName, + branchFrom: worktree.parentWorktreeId?.split(/[\\/]/).at(-1) ?? null, + parent: agent.parentPaneKey ?? null, + status: agent.state, + startedAt, + startSource: transcriptStart + ? 'agent-session' + : terminalStart + ? 'terminal-tab' + : 'current-state', + completedAt: agent.state === 'done' ? (agent.updatedAt ?? null) : null, + updatedAt: agent.updatedAt ?? 0, + unread: worktree.unread === true, + detail: + agent.state === 'working' && agent.toolName + ? `Using ${agent.toolName}` + : agent.state === 'working' + ? 'Working now' + : 'Finished' + } + }) +) + +const snapshot = { + capturedAt, + sourceSummary: { + worktrees: worktrees.filter((worktree) => (worktree.agents?.length ?? 0) > 0).length, + agents: allAgents.length, + working: allAgents.filter((agent) => agent.state === 'working').length, + done: allAgents.filter((agent) => agent.state === 'done').length, + parentLinked: allAgents.filter((agent) => agent.parentPaneKey).length, + exactSessionStarts: capturedAgents.filter((agent) => agent.startSource === 'agent-session') + .length + }, + agents: capturedAgents +} + +const json = `${JSON.stringify(snapshot, null, 2)}\n` +writeFileSync(join(directory, 'actual-agent-activity.json'), json) +writeFileSync( + join(directory, 'actual-agent-activity.js'), + `window.AGENT_ACTIVITY_SNAPSHOT = ${json}` +) diff --git a/agent-viewer-alternatives/index.html b/agent-viewer-alternatives/index.html new file mode 100644 index 00000000000..c7d3d244349 --- /dev/null +++ b/agent-viewer-alternatives/index.html @@ -0,0 +1,255 @@ + + + + + + + Orca Global Agent Viewer Alternatives + + + + + + +
    +
    +
    + + AgentsGlobal viewer alternatives +
    + + +
    + +
    + + + + + + +
    + +
    +
    + Current-data prototype +

    Activity lanes

    +

    + Each agent runs from its session start to completion or Now; children stay with their + parent. +

    +
    +
    + Tradeoff + Completed sessions older than two hours stay collapsed until you ask for them. +
    +
    +
    + +
    +
    +
    +
    +
    + + +
    + +
    +
    + + + Talk to any agentDrop an agent here or use Add to Talk + + +
    + + +

    + Nothing sends until you choose Send follow-up. +

    +
    +
    + +
    + + + + diff --git a/agent-viewer-alternatives/lineage-atlas-light.png b/agent-viewer-alternatives/lineage-atlas-light.png new file mode 100644 index 00000000000..c5d683ab2e8 Binary files /dev/null and b/agent-viewer-alternatives/lineage-atlas-light.png differ diff --git a/agent-viewer-alternatives/queue-cells-dark.png b/agent-viewer-alternatives/queue-cells-dark.png new file mode 100644 index 00000000000..957e2fee08d Binary files /dev/null and b/agent-viewer-alternatives/queue-cells-dark.png differ diff --git a/agent-viewer-alternatives/styles.css b/agent-viewer-alternatives/styles.css new file mode 100644 index 00000000000..fa503d1706e --- /dev/null +++ b/agent-viewer-alternatives/styles.css @@ -0,0 +1,1546 @@ +@font-face { + font-family: Geist; + src: url('../src/renderer/src/assets/fonts/Geist-Variable.woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +:root { + --font-sans: Geist, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, monospace; + --radius: 0.625rem; + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --background: #fff; + --editor-surface: #fff; + --foreground: #0a0a0a; + --card: #fff; + --card-foreground: #0a0a0a; + --popover: #fff; + --popover-foreground: #0a0a0a; + --primary: #171717; + --primary-foreground: #fafafa; + --secondary: #f5f5f5; + --secondary-foreground: #171717; + --muted: #f5f5f5; + --muted-foreground: #737373; + --accent: #f5f5f5; + --accent-foreground: #171717; + --border: #e5e5e5; + --input: #e5e5e5; + --ring: #a1a1a1; + --status-success: #15803d; + --sidebar: #fafafa; + --sidebar-foreground: #0a0a0a; + --sidebar-accent: #f5f5f5; + --sidebar-border: #e5e5e5; + --color-yellow-500: #eab308; + --color-amber-500: #f59e0b; + --color-red-500: #ef4444; + --color-emerald-500: #10b981; + --color-neutral-500: #737373; + --agent-size: 36px; +} + +.dark { + --background: #0a0a0a; + --editor-surface: #1e1e1e; + --foreground: #fafafa; + --card: #171717; + --card-foreground: #fafafa; + --popover: #171717; + --popover-foreground: #fafafa; + --primary: #e5e5e5; + --primary-foreground: #171717; + --secondary: #262626; + --secondary-foreground: #fafafa; + --muted: #262626; + --muted-foreground: #a1a1a1; + --accent: #404040; + --accent-foreground: #fafafa; + --border: rgb(255 255 255 / 0.07); + --input: rgb(255 255 255 / 0.15); + --ring: #737373; + --status-success: #86efac; + --sidebar: #171717; + --sidebar-foreground: #fafafa; + --sidebar-accent: #262626; + --sidebar-border: rgb(255 255 255 / 0.07); +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + min-height: 100%; +} + +html { + background: var(--background); +} + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font: 14px/1.45 var(--font-sans); + letter-spacing: 0.01em; +} + +button, +input, +select { + font: inherit; +} + +button, +select { + color: inherit; +} + +button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +[tabindex='0']:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.svg-defs, +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.icon { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.app-shell { + display: flex; + min-height: 100vh; + min-width: 0; + flex-direction: column; + overflow-x: hidden; + padding-bottom: 78px; +} + +.app-header { + position: sticky; + z-index: 20; + top: 0; + display: grid; + min-height: 58px; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--background) 94%, transparent); + backdrop-filter: blur(12px); +} + +.brand, +.brand > span:last-child { + display: flex; +} + +.brand { + align-items: center; + gap: 9px; +} + +.brand > span:last-child { + min-width: 0; + flex-direction: column; +} + +.brand strong { + font-size: 14px; +} + +.brand small { + color: var(--muted-foreground); + font-size: 11px; +} + +.orca-mark { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: var(--radius-md); + background: var(--primary); + color: var(--primary-foreground); + font-weight: 800; +} + +.concept-tabs { + display: flex; + width: fit-content; + max-width: 100%; + justify-self: center; + gap: 2px; + overflow-x: auto; + padding: 3px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.concept-tab { + min-height: 30px; + padding: 0 12px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted-foreground); + font-size: 12px; + white-space: nowrap; +} + +.concept-tab:hover { + background: var(--accent); + color: var(--accent-foreground); +} + +.concept-tab.is-active { + background: var(--secondary); + color: var(--secondary-foreground); + box-shadow: inset 0 0 0 1px var(--border); +} + +.icon-button { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; +} + +.icon-button:hover { + border-color: var(--border); + background: var(--accent); +} + +.filter-bar { + display: flex; + min-width: 0; + align-items: end; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + background: var(--card); +} + +.search-field { + position: relative; + display: flex; + min-width: 240px; + flex: 1 1 360px; + align-items: center; +} + +.search-field > .icon { + position: absolute; + left: 9px; + width: 14px; + color: var(--muted-foreground); +} + +.search-field input, +.talk-input input { + width: 100%; + height: 32px; + border: 1px solid var(--input); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--muted) 55%, var(--background)); + color: var(--foreground); +} + +.search-field input { + padding: 0 58px 0 30px; +} + +.search-field kbd { + position: absolute; + right: 7px; + padding: 1px 5px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--muted-foreground); + font-size: 9px; + line-height: 17px; +} + +.compact-field { + display: flex; + min-width: 112px; + max-width: 172px; + flex-direction: column; + flex: 1 1 132px; + gap: 3px; +} + +.compact-field > span { + padding-left: 2px; + color: var(--muted-foreground); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.compact-field select { + width: 100%; + min-width: 0; + height: 32px; + padding: 0 28px 0 9px; + border: 1px solid var(--input); + border-radius: var(--radius-md); + background: var(--background); + font-size: 12px; +} + +.button { + display: inline-flex; + min-height: 32px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; + border-radius: var(--radius-md); + font-size: 12px; + font-weight: 500; +} + +.button:disabled { + cursor: default; + opacity: 0.45; +} + +.button-outline { + border: 1px solid var(--border); + background: var(--background); +} + +.button-outline:hover:not(:disabled) { + background: var(--accent); +} + +.button-primary { + border: 1px solid var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + +.older-toggle { + flex: 0 0 auto; +} + +.older-toggle[aria-pressed='true'] { + background: var(--secondary); +} + +.concept-summary { + display: grid; + grid-template-columns: minmax(240px, 1fr) minmax(260px, 420px) auto; + align-items: center; + gap: 24px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.concept-summary h1 { + margin: 2px 0 1px; + font-size: 16px; + line-height: 1.25; +} + +.concept-summary p { + margin: 0; + color: var(--muted-foreground); + font-size: 12px; +} + +.eyebrow { + color: var(--muted-foreground); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.tradeoff { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px; + padding: 7px 9px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--muted) 55%, transparent); + font-size: 11px; +} + +.tradeoff span { + color: var(--muted-foreground); +} + +.filter-result { + color: var(--muted-foreground); + font-size: 11px; + text-align: right; + white-space: nowrap; +} + +.viewer-layout { + display: grid; + min-height: 0; + min-width: 0; + flex: 1 1 auto; + grid-template-columns: minmax(0, 1fr); +} + +.viewer-layout.has-panel { + grid-template-columns: minmax(0, 1fr) minmax(330px, 390px); +} + +.concept-stage { + min-width: 0; + overflow: auto; +} + +.concept-panel { + min-height: 100%; +} + +.empty-state { + display: grid; + min-height: 320px; + place-items: center; + align-content: center; + gap: 7px; + color: var(--muted-foreground); +} + +.empty-state strong { + color: var(--foreground); +} + +.queue-view { + display: grid; + gap: 12px; + padding: 14px 16px 18px; +} + +.queue-band { + display: grid; + gap: 7px; +} + +.queue-band > header { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 2px; +} + +.queue-band > header h2 { + margin: 0; + font-size: 12px; +} + +.queue-band > header span { + margin-left: auto; + color: var(--muted-foreground); + font-size: 10px; +} + +.cell-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 7px; +} + +.agent-card { + position: relative; + display: grid; + min-width: 0; + grid-template-columns: var(--agent-size) minmax(0, 1fr) auto; + grid-template-rows: auto auto; + gap: 4px 9px; + align-items: center; + padding: 9px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); +} + +.agent-card.has-fresh-reply { + box-shadow: inset 2px 0 var(--color-amber-500); +} + +.agent-trigger { + display: contents; + border: 0; + background: transparent; + color: inherit; + text-align: left; +} + +.agent-card:hover { + background: color-mix(in srgb, var(--accent) 55%, var(--card)); +} + +.agent-card.is-selected { + outline: 1px solid var(--ring); + outline-offset: -1px; +} + +.agent-icon { + position: relative; + display: grid; + width: var(--agent-size); + height: var(--agent-size); + flex: 0 0 var(--agent-size); + place-items: center; + border: 1.5px solid var(--border); + border-radius: 999px; + background: var(--background); +} + +.agent-icon > .icon { + width: 18px; + height: 18px; +} + +.status-working .agent-icon { + border-color: var(--color-yellow-500); + box-shadow: 0 0 7px color-mix(in srgb, var(--color-yellow-500) 70%, transparent); +} + +.status-done .agent-icon { + border-color: var(--color-emerald-500); + box-shadow: 0 0 6px color-mix(in srgb, var(--color-emerald-500) 48%, transparent); +} + +.status-attention .agent-icon { + border-color: var(--color-amber-500); +} + +.status-blocked .agent-icon { + border-color: var(--color-red-500); +} + +.status-badge { + position: absolute; + top: -3px; + left: -3px; + display: grid; + width: 14px; + height: 14px; + place-items: center; + border-radius: 999px; + background: var(--background); +} + +.status-badge::before { + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--color-neutral-500); + content: ''; +} + +.status-working .status-badge::before { + width: 8px; + height: 8px; + border: 2px solid color-mix(in srgb, var(--color-yellow-500) 32%, transparent); + border-top-color: var(--color-yellow-500); + background: transparent; + animation: spin 0.8s linear infinite; +} + +.status-done .status-badge::before { + width: 8px; + height: 8px; + background: var(--color-emerald-500); +} + +.status-attention .status-badge::before { + background: var(--color-amber-500); +} + +.status-blocked .status-badge::before { + background: var(--color-red-500); +} + +.agent-primary { + min-width: 0; + align-self: end; +} + +.agent-primary strong, +.agent-primary span, +.agent-secondary { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-primary strong { + font-size: 12px; +} + +.agent-primary span { + color: var(--muted-foreground); + font-size: 10px; +} + +.agent-age { + align-self: start; + color: var(--muted-foreground); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.agent-secondary { + grid-column: 2 / 4; + color: var(--muted-foreground); + font-size: 11px; +} + +.add-recipient { + position: absolute; + right: 5px; + bottom: 4px; + display: grid; + width: 24px; + height: 24px; + place-items: center; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted-foreground); +} + +.add-recipient:hover { + background: var(--accent); + color: var(--foreground); +} + +.add-recipient .icon { + width: 13px; + height: 13px; +} + +.lineage-view { + display: grid; + gap: 14px; + padding: 14px 16px 22px; +} + +.project-lineage { + display: grid; + gap: 10px; + padding: 11px; + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: color-mix(in srgb, var(--card) 72%, transparent); +} + +.project-lineage > header { + display: flex; + align-items: baseline; + gap: 8px; + padding: 0 2px; +} + +.project-lineage > header h2 { + margin: 0; + font-size: 13px; +} + +.project-lineage > header span { + color: var(--muted-foreground); + font-size: 10px; +} + +.project-lineage > header time { + margin-left: auto; + color: var(--muted-foreground); + font-size: 10px; +} + +.worktree-branches { + display: flex; + align-items: flex-start; + gap: 12px; + overflow-x: auto; + padding: 2px 2px 8px; +} + +.worktree-tree { + position: relative; + min-width: 260px; + max-width: 340px; + flex: 1 0 280px; + padding: 10px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--card); +} + +.worktree-tree.is-branch::before { + position: absolute; + top: -8px; + left: 24px; + width: 28px; + height: 8px; + border-top: 1px solid var(--border); + border-left: 1px solid var(--border); + content: ''; +} + +.worktree-heading { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding-bottom: 9px; + border-bottom: 1px solid var(--border); +} + +.worktree-heading > .icon { + width: 14px; + height: 14px; + color: var(--muted-foreground); +} + +.worktree-heading strong, +.worktree-heading small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.worktree-heading strong { + font-size: 11px; +} + +.worktree-heading small, +.worktree-heading time { + color: var(--muted-foreground); + font-size: 9px; +} + +.agent-tree { + padding-top: 10px; +} + +.tree-root { + width: 100%; +} + +.tree-children { + position: relative; + display: grid; + gap: 7px; + margin: 8px 0 0 18px; + padding-left: 15px; +} + +.tree-children::before { + position: absolute; + top: -8px; + bottom: 17px; + left: 0; + border-left: 1px solid var(--border); + content: ''; +} + +.tree-child { + position: relative; +} + +.tree-child::before { + position: absolute; + top: 21px; + left: -15px; + width: 15px; + border-top: 1px solid var(--border); + content: ''; +} + +.tree-agent { + position: relative; + display: grid; + width: 100%; + grid-template-columns: var(--agent-size) minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 7px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--background); + color: inherit; + text-align: left; +} + +.tree-agent:hover { + background: var(--accent); +} + +.tree-agent.is-selected { + outline: 1px solid var(--ring); +} + +.tree-agent .agent-copy { + min-width: 0; +} + +.tree-agent .agent-copy strong, +.tree-agent .agent-copy span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-agent .agent-copy strong { + font-size: 11px; +} + +.tree-agent .agent-copy span, +.tree-agent time { + color: var(--muted-foreground); + font-size: 9px; +} + +.tree-add { + position: absolute; + right: 5px; + bottom: 3px; +} + +.lanes-view { + min-width: 960px; + padding: 14px 16px 24px; +} + +.lane-axis, +.activity-lane { + display: grid; + grid-template-columns: 220px minmax(700px, 1fr); +} + +.lane-axis { + position: sticky; + z-index: 2; + top: 0; + border-bottom: 1px solid var(--border); + background: var(--background); +} + +.axis-label { + padding: 8px 10px; + color: var(--muted-foreground); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.axis-track { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px; + border-left: 1px solid var(--border); + color: var(--muted-foreground); + font-size: 10px; +} + +.activity-lane { + border-bottom: 1px solid var(--border); +} + +.lane-heading { + display: grid; + align-content: start; + gap: 2px; + padding: 14px 10px; +} + +.lane-heading strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lane-heading span, +.lane-heading time { + color: var(--muted-foreground); + font-size: 10px; +} + +.lane-track { + min-width: 0; + border-left: 1px solid var(--border); +} + +.lane-timeline { + position: relative; + margin: 0 18px; + background-image: linear-gradient(to right, var(--border) 1px, transparent 1px); + background-position: left top; + background-size: 25% 100%; + border-right: 1px solid var(--border); +} + +.timeline-agent { + position: relative; + z-index: 1; + display: block; + width: 100%; + height: 52px; + padding: 0; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: inherit; + text-align: left; +} + +.timeline-agent:hover { + background: color-mix(in srgb, var(--accent) 44%, transparent); +} + +.timeline-agent.is-selected { + outline: 1px solid var(--ring); + outline-offset: -1px; + background: color-mix(in srgb, var(--accent) 44%, transparent); +} + +.timeline-segment { + position: absolute; + top: 37px; + left: var(--timeline-line-start); + width: var(--timeline-width); + height: 2px; + border-radius: 999px; + background: var(--color-neutral-500); +} + +.status-working .timeline-segment { + background: color-mix(in srgb, var(--color-yellow-500) 72%, transparent); +} + +.status-done .timeline-segment { + background: color-mix(in srgb, var(--color-emerald-500) 68%, transparent); +} + +.status-attention .timeline-segment { + background: color-mix(in srgb, var(--color-amber-500) 72%, transparent); +} + +.status-blocked .timeline-segment { + background: color-mix(in srgb, var(--color-red-500) 72%, transparent); +} + +.timeline-agent-label { + position: absolute; + top: 3px; + left: calc(var(--timeline-start) + 22px); + display: flex; + width: min(340px, 42%); + min-width: 0; + flex-direction: column; + line-height: 1.25; +} + +.timeline-agent-label.is-label-left { + left: calc(var(--timeline-start) - 22px); + align-items: end; + transform: translateX(-100%); + text-align: right; +} + +.timeline-agent-label strong, +.timeline-agent-label small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.timeline-agent-label strong { + font-size: 12px; +} + +.timeline-agent-label small { + color: var(--muted-foreground); + font-size: 10px; +} + +.timeline-agent > .agent-icon { + position: absolute; + z-index: 3; + top: 37px; + left: var(--timeline-start); + transform: translate(-50%, -50%); +} + +.timeline-endpoint { + position: absolute; + z-index: 4; + top: 37px; + left: var(--timeline-end); + display: grid; + width: 14px; + height: 14px; + place-items: center; + transform: translate(-50%, -50%); + border-radius: 999px; + background: var(--background); +} + +.timeline-endpoint::before { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--color-neutral-500); + content: ''; +} + +.status-working .timeline-endpoint { + box-shadow: 0 0 7px color-mix(in srgb, var(--color-yellow-500) 70%, transparent); +} + +.status-working .timeline-endpoint::before { + width: 10px; + height: 10px; + border: 2px solid color-mix(in srgb, var(--color-yellow-500) 32%, transparent); + border-top-color: var(--color-yellow-500); + background: transparent; + animation: spin 0.8s linear infinite; +} + +.status-done .timeline-endpoint { + box-shadow: 0 0 6px color-mix(in srgb, var(--color-emerald-500) 48%, transparent); +} + +.status-done .timeline-endpoint::before { + background: var(--color-emerald-500); +} + +.status-attention .timeline-endpoint::before { + background: var(--color-amber-500); +} + +.status-blocked .timeline-endpoint::before { + background: var(--color-red-500); +} + +.timeline-response-tick { + position: absolute; + z-index: 2; + top: 32px; + left: var(--timeline-response); + width: 1px; + height: 11px; + background: var(--foreground); +} + +.timeline-agent.is-clipped-start .timeline-segment { + border-radius: 0 999px 999px 0; +} + +.lane-lineage { + position: absolute; + z-index: 0; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.lane-lineage path { + fill: none; + stroke: var(--muted-foreground); + stroke-width: 1; + opacity: 0.5; + vector-effect: non-scaling-stroke; +} + +.agent-panel { + position: sticky; + top: 58px; + display: flex; + min-height: 540px; + min-width: 0; + max-height: calc(100vh - 58px); + flex-direction: column; + overflow: hidden; + border-left: 1px solid var(--border); + background: var(--popover); + color: var(--popover-foreground); +} + +.panel-header { + display: flex; + align-items: center; + gap: 9px; + padding: 11px 12px; + border-bottom: 1px solid var(--border); +} + +.panel-header h2, +.panel-header p { + margin: 0; +} + +.panel-header h2 { + font-size: 13px; +} + +.panel-header p { + color: var(--muted-foreground); + font-size: 10px; +} + +.panel-header .icon-button { + margin-left: auto; +} + +.panel-status-line { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + font-size: 11px; +} + +.panel-status-line span { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; +} + +.panel-status-line span::before { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--color-neutral-500); + content: ''; +} + +.panel-status-line .working::before { + border: 2px solid color-mix(in srgb, var(--color-yellow-500) 35%, transparent); + border-top-color: var(--color-yellow-500); + background: transparent; + animation: spin 0.8s linear infinite; +} + +.panel-status-line .done::before { + background: var(--color-emerald-500); +} + +.panel-status-line .attention::before { + background: var(--color-amber-500); +} + +.panel-status-line .blocked::before { + background: var(--color-red-500); +} + +.panel-status-line time { + margin-left: auto; + color: var(--muted-foreground); +} + +.panel-summary, +.panel-files { + padding: 11px 12px; +} + +.panel-summary p { + margin: 6px 0 0; + font-size: 12px; +} + +.terminal-frame { + display: flex; + min-height: 210px; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; + margin: 0 10px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--editor-surface); +} + +.terminal-frame header { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 9px; + border-bottom: 1px solid var(--border); + color: var(--muted-foreground); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.05em; +} + +.terminal-frame header .icon { + width: 12px; + height: 12px; +} + +.terminal-frame header b { + margin-left: auto; + color: var(--status-success); + font-size: 8px; +} + +.terminal-frame pre { + flex: 1; + margin: 0; + overflow: auto; + padding: 10px; + color: var(--foreground); + font: 11px/1.55 var(--font-mono); + white-space: pre-wrap; +} + +.panel-files { + border-bottom: 1px solid var(--border); +} + +.panel-files ul { + display: grid; + gap: 4px; + margin: 7px 0 0; + padding: 0; + list-style: none; +} + +.panel-files li { + display: flex; + align-items: baseline; + gap: 7px; + color: var(--muted-foreground); + font: 10px var(--font-mono); + overflow-wrap: anywhere; +} + +.panel-files li::before { + color: var(--status-success); + content: 'M'; +} + +.panel-actions { + display: flex; + gap: 7px; + padding: 9px 10px; +} + +.panel-actions .button { + min-width: 0; + flex: 1; +} + +.talk-dock { + position: fixed; + z-index: 25; + right: 0; + bottom: 0; + left: 0; + display: grid; + min-height: 62px; + grid-template-columns: minmax(280px, 520px) minmax(220px, 1fr) auto; + grid-template-rows: auto auto; + align-items: center; + gap: 6px 10px; + padding: 8px 12px; + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 96%, transparent); + backdrop-filter: blur(12px); +} + +.talk-drop { + display: flex; + min-height: 38px; + min-width: 0; + align-items: center; + gap: 10px; + padding: 4px 7px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); +} + +.talk-drop.is-drag-over { + border-color: var(--ring); + background: var(--accent); +} + +.talk-label { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 7px; +} + +.talk-label > .icon { + color: var(--muted-foreground); +} + +.talk-label span { + display: flex; + flex-direction: column; +} + +.talk-label strong { + font-size: 11px; +} + +.talk-label small { + color: var(--muted-foreground); + font-size: 9px; +} + +.recipient-list { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 4px; +} + +.recipient-chip { + display: inline-flex; + height: 24px; + align-items: center; + gap: 4px; + padding: 0 4px 0 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--muted); + font-size: 10px; +} + +.recipient-chip button { + display: grid; + width: 17px; + height: 17px; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted-foreground); +} + +.recipient-chip .icon { + width: 10px; + height: 10px; +} + +.talk-input input { + padding: 0 10px; +} + +.talk-note { + grid-column: 1 / 4; + margin: 0; + color: var(--muted-foreground); + font-size: 9px; +} + +.toast { + position: fixed; + z-index: 40; + right: 14px; + bottom: 76px; + max-width: min(360px, calc(100vw - 28px)); + padding: 9px 11px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--popover); + color: var(--popover-foreground); + box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); + font-size: 11px; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.toast.is-visible { + opacity: 1; + transform: translateY(0); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 1120px) { + .filter-bar { + flex-wrap: wrap; + } + + .search-field { + flex-basis: 100%; + } + + .concept-summary { + grid-template-columns: 1fr auto; + } + + .tradeoff { + grid-column: 1 / 3; + grid-row: 2; + } + + .viewer-layout.has-panel { + grid-template-columns: minmax(0, 1fr) minmax(310px, 350px); + } +} + +@media (max-width: 820px) { + .app-shell { + padding-bottom: 0; + } + + .app-header { + grid-template-columns: minmax(0, 1fr) auto; + } + + .concept-tabs { + grid-column: 1 / 3; + grid-row: 2; + justify-self: stretch; + } + + .concept-tab { + flex: 1; + } + + .compact-field { + flex: 1 1 140px; + } + + .concept-summary { + grid-template-columns: 1fr; + } + + .tradeoff { + grid-column: auto; + grid-row: auto; + } + + .filter-result { + text-align: left; + } + + .viewer-layout.has-panel { + grid-template-columns: minmax(0, 1fr); + } + + .agent-panel { + position: relative; + top: 0; + max-height: none; + border-top: 1px solid var(--border); + border-left: 0; + } + + .talk-dock { + position: relative; + grid-template-columns: 1fr auto; + } + + .talk-drop { + grid-column: 1 / 3; + } + + .talk-note { + grid-column: 1 / 3; + } +} + +@media (max-width: 540px) { + .brand small, + .search-field kbd, + .talk-label small { + display: none; + } + + .filter-bar, + .concept-summary, + .queue-view, + .lineage-view, + .lanes-view { + padding-right: 10px; + padding-left: 10px; + } + + .older-toggle { + flex: 1 1 100%; + } + + .talk-dock { + grid-template-columns: 1fr; + } + + .talk-drop, + .talk-note { + grid-column: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index eb4addedd5a..5085163aed5 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -306,18 +306,19 @@ function getDevUserDataPath() { if (process.env.ORCA_DEV_USER_DATA_PATH) { return process.env.ORCA_DEV_USER_DATA_PATH } + const profileDirectory = process.env.ORCA_DEV_PRODUCTION_PROFILE === '1' ? 'orca' : 'orca-dev' if (process.platform === 'darwin') { - return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev') + return path.join(process.env.HOME ?? '', 'Library', 'Application Support', profileDirectory) } if (process.platform === 'win32') { return path.join( process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), - 'orca-dev' + profileDirectory ) } return path.join( process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), - 'orca-dev' + profileDirectory ) } diff --git a/src/main/index.ts b/src/main/index.ts index a80bea0d965..4214a7db93a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -114,11 +114,13 @@ import { configureElectronNetworkCompatibility, configureDevUserDataPath, configureOrcaUserDataPathEnv, + devParentShutdownOwnsDaemon, enableMainProcessGpuFeatures, installDevParentDisconnectQuit, installDevParentSignalQuit, installDevParentWatchdog, isDevParentShutdownRequested, + isProductionProfileDevMode, patchPackagedProcessPath, shouldInstallManagedHooks } from './startup/configure-process' @@ -154,7 +156,10 @@ import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entr import { startFirstWindowStartupServices } from './startup/first-window-startup-services' import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery' import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' -import { getDevInstanceIdentity } from './startup/dev-instance-identity' +import { + getDevInstanceIdentity, + resolveDevAgentHookEndpointNamespace +} from './startup/dev-instance-identity' import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' import { acquireSingleInstanceLock, @@ -550,9 +555,13 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: { } const devInstanceIdentity = getDevInstanceIdentity(is.dev) -const devAgentHookEndpointNamespace = devInstanceIdentity.isDev - ? devInstanceIdentity.appUserModelId - : undefined +const productionProfileDevMode = isProductionProfileDevMode(is.dev) +// Why: production-profile dev is a desktop preview; accepting paired clients would impersonate the packaged install. +const enableMobileTransports = !productionProfileDevMode +const devAgentHookEndpointNamespace = resolveDevAgentHookEndpointNamespace( + devInstanceIdentity, + productionProfileDevMode +) installUncaughtPipeErrorGuard() // Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). @@ -2739,7 +2748,7 @@ void app.whenReady().then(async () => { runtime, // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. userDataPath: getCanonicalUserDataPath(), - enableWebSocket: true, + enableWebSocket: enableMobileTransports, ...(isE2E ? { wsPort: e2eWsPort } : {}), ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), ...(serveOptions?.wsPort !== undefined @@ -2870,7 +2879,7 @@ void app.whenReady().then(async () => { } const cloudAuth = getOrcaCloudAuthConfig() - if (cloudAuth.configured) { + if (cloudAuth.configured && enableMobileTransports) { try { const relayService = new DesktopRelayService({ authConfig: cloudAuth.config, @@ -3008,7 +3017,10 @@ app.on('will-quit', (e) => { // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. - const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + const daemonTeardown = + isDevParentShutdownRequested() && devParentShutdownOwnsDaemon(is.dev) + ? shutdownDaemon() + : disconnectDaemon() // Why: a wedged transport (half-open post-sleep socket) can leave one // member unsettled forever and block app.quit() until Force Quit (#9447). settleTeardownWithinDeadline([ diff --git a/src/main/ipc/dashboard-agent-launch-validation.ts b/src/main/ipc/dashboard-agent-launch-validation.ts new file mode 100644 index 00000000000..6bd9dbe5838 --- /dev/null +++ b/src/main/ipc/dashboard-agent-launch-validation.ts @@ -0,0 +1,38 @@ +import type { DashboardSpawnAgentArgs } from '../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../shared/tui-agent-config' + +const MAX_ID_LENGTH = 4_096 +const MAX_WORKTREES = 500 +const MAX_AGENTS_PER_WORKTREE = 64 + +function isBoundedId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH +} + +export function isDashboardSpawnAgentArgs(value: unknown): value is DashboardSpawnAgentArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const args = value as Record + return isBoundedId(args.worktreeId) && isTuiAgent(args.agent) +} + +export function isDashboardLaunchOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entries = Object.entries(value as Record) + return ( + entries.length <= MAX_WORKTREES && + entries.every( + ([worktreeId, agents]) => + isBoundedId(worktreeId) && + Array.isArray(agents) && + agents.length <= MAX_AGENTS_PER_WORKTREE && + agents.every(isTuiAgent) + ) + ) +} diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 474db1f0f0c..cd30398b3f4 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -19,6 +19,7 @@ vi.mock('../../shared/repo-icon', async (importOriginal) => { import { admitDashboardSnapshot, isDashboardRevealAgentArgs, + isDashboardSpawnAgentArgs, isDashboardSnapshot } from './dashboard-payload-validation' @@ -34,6 +35,7 @@ const SNAPSHOT = { task: 'Review the dashboard', lastUserMessage: 'Please review this', lastAgentMessage: 'I need a decision.', + lastResponseAt: 1_699_999_600_000, repoId: 'repo-1', worktreeId: 'worktree-1', tabId: 'tab-1', @@ -123,6 +125,46 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], parentPaneKey: 'x'.repeat(4_097) }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], lastResponseAt: Number.NaN }] + }) + ).toBe(false) + }) + + it('validates the optional chat session identity instead of passing it through', () => { + // Absent is the normal case — the hook has not reported a session yet. + expect(isDashboardSnapshot(SNAPSHOT)).toBe(true) + const withSession = { + ...SNAPSHOT, + cards: [ + { + ...SNAPSHOT.cards[0], + sessionId: 'b6e5f0aa-1f1e-4f6c-9f4d-2f3a5c7d9e11', + transcriptPath: '/Users/dev/.claude/projects/orca/session.jsonl' + } + ] + } + expect(isDashboardSnapshot(withSession)).toBe(true) + // A widened card must still be admitted, not silently dropped from the board. + expect(admitDashboardSnapshot(withSession)?.droppedCardCount).toBe(0) + + expect( + isDashboardSnapshot({ ...SNAPSHOT, cards: [{ ...SNAPSHOT.cards[0], sessionId: 42 }] }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], transcriptPath: { path: '/tmp/x' } }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], transcriptPath: `/${'x'.repeat(4_096)}` }] + }) + ).toBe(false) }) it('accepts repo icons a pop-out can safely render, and rejects the rest', () => { @@ -183,6 +225,26 @@ describe('dashboard payload validation', () => { ).toBe(false) }) + it('validates bounded launch choices and spawn requests', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['codex', 'claude'] } + }) + ).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['not-an-agent'] } + }) + ).toBe(false) + expect(isDashboardSnapshot({ ...SNAPSHOT, launchableAgentsByWorktreeId: [] })).toBe(false) + + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'codex' })).toBe(true) + expect(isDashboardSpawnAgentArgs({ worktreeId: '', agent: 'codex' })).toBe(false) + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'unknown' })).toBe(false) + }) + it('bounds the conversation name', () => { expect( isDashboardSnapshot({ diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 1a70c1423b4..369295dc6e9 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -11,6 +11,8 @@ import { AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_TYPE_MAX_LENGTH } from '../../shared/agent-status-types' +import { isDashboardLaunchOptions } from './dashboard-agent-launch-validation' +export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation' const MAX_DASHBOARD_CARDS = 1_000 const MAX_DASHBOARD_SUBAGENTS = 100 @@ -87,6 +89,7 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot snapshot.cards.every(isDashboardCard) && (snapshot.showIdle === undefined || typeof snapshot.showIdle === 'boolean') && isDashboardFilterOptions(snapshot.filterOptions) && + isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) && isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) } @@ -115,6 +118,7 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi snapshot.cards.length > MAX_DASHBOARD_CARDS || (snapshot.showIdle !== undefined && typeof snapshot.showIdle !== 'boolean') || !isDashboardFilterOptions(snapshot.filterOptions) || + !isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) || !isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) { return null @@ -262,6 +266,7 @@ function isDashboardCard(value: unknown): boolean { isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) && isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) && isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) && + (card.lastResponseAt === undefined || isFiniteNumber(card.lastResponseAt)) && isBoundedString(card.repoId, MAX_ID_LENGTH) && isBoundedString(card.worktreeId, MAX_ID_LENGTH) && isBoundedString(card.tabId, MAX_ID_LENGTH) && @@ -286,7 +291,10 @@ function isDashboardCard(value: unknown): boolean { typeof card.unseen === 'boolean' && isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) && isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) && - isDashboardTerminalInput(card.terminalInput) + isDashboardTerminalInput(card.terminalInput) && + isOptionalBoundedString(card.sessionId, MAX_ID_LENGTH) && + // MAX_ID_LENGTH doubles as the path bound: it already matches PATH_MAX. + isOptionalBoundedString(card.transcriptPath, MAX_ID_LENGTH) ) } diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index d516faa4554..e5f5ae65222 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -240,6 +240,19 @@ describe('registerDashboardPopoutHandlers', () => { expect(sendToTrustedMock).toHaveBeenCalledWith('ui:ackDashboardAgent', 'tab1:leaf1') }) + it('relays only valid agent launches from the popout', () => { + const args = { worktreeId: 'worktree-1', agent: 'codex' } + handlers.get('dashboardPopout:spawnAgent')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, { + ...args, + agent: 'unknown' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:spawnDashboardAgent', args) + }) + it('reveals an agent in only the trusted main window', () => { const main = makeWindow(mainSender) getTrustedWindowMock.mockReturnValue(main) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index c7a26421878..07dc6783dc6 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -14,7 +14,8 @@ import { getTrustedUIRendererWindow, isTrustedUIRenderer, sendToTrustedUIRendere import { admitDashboardSnapshot, isDashboardPaneKey, - isDashboardRevealAgentArgs + isDashboardRevealAgentArgs, + isDashboardSpawnAgentArgs } from './dashboard-payload-validation' // The most recent snapshot the main renderer published, replayed to the popout @@ -36,6 +37,7 @@ export function registerDashboardPopoutHandlers( ipcMain.removeHandler('dashboard:getPopoutOpen') ipcMain.removeHandler('dashboardPopout:revealAgent') ipcMain.removeHandler('dashboardPopout:ackAgent') + ipcMain.removeHandler('dashboardPopout:spawnAgent') onDashboardPopoutOpenChanged((open) => { if (!open) { @@ -141,4 +143,15 @@ export function registerDashboardPopoutHandlers( // Best-effort; the per-window focus above may still bring it forward. } }) + + ipcMain.handle('dashboardPopout:spawnAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSpawnAgentArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:spawnDashboardAgent', args) + }) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e89bf7fb214..ff46ce46b36 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -37047,7 +37047,7 @@ describe('OrcaRuntimeService', () => { const workerPaneKey = makePaneKey('tab-worker', workerLeafId) const coordinatorPaneKey = makePaneKey('tab-coordinator', coordinatorLeafId) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') - const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') + const staleCoordinatorHandle = 'term-stale-coordinator' runtime.setOrchestrationDb({ getActiveDispatchForTerminal: vi.fn(() => undefined), getLatestDispatchForTerminal: vi.fn((handle: string) => @@ -37063,7 +37063,13 @@ describe('OrcaRuntimeService', () => { ), getTask: vi.fn(() => ({ id: 'task-done', - created_by_terminal_handle: coordinatorHandle + run_id: 'run-done', + created_by_terminal_handle: staleCoordinatorHandle + })), + getRun: vi.fn(() => ({ + id: 'run-done', + coordinator_handle: staleCoordinatorHandle, + coordinator_pane_key: coordinatorPaneKey })) } as never) runtime.attachWindow(1) @@ -37110,7 +37116,9 @@ describe('OrcaRuntimeService', () => { dispatchId: 'ctx-done', dispatchStatus: 'completed', parentPaneKey: coordinatorPaneKey, - parentTerminalHandle: coordinatorHandle + parentTerminalHandle: staleCoordinatorHandle, + coordinatorHandle: staleCoordinatorHandle, + orchestrationRunId: 'run-done' }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 963c91811b9..0a65a8cbbe4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -29476,18 +29476,18 @@ export class OrcaRuntimeService { displayName: task.display_name }) : { taskTitle: '', displayName: '' } - const activeRun = - dispatch.status === 'pending' || dispatch.status === 'dispatched' - ? db?.getActiveCoordinatorRun?.() - : undefined + const isSettled = dispatch.status !== 'pending' && dispatch.status !== 'dispatched' + const storedRun = isSettled && task?.run_id ? db?.getRun?.(task.run_id) : undefined + const activeRun = isSettled ? undefined : db?.getActiveCoordinatorRun?.() + const orchestrationRun = storedRun ?? activeRun const parentTerminalHandle = task?.created_by_terminal_handle ?? - (activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle - ? activeRun.coordinator_handle + (orchestrationRun?.coordinator_handle && orchestrationRun.coordinator_handle !== handle + ? orchestrationRun.coordinator_handle : undefined) - const parentPaneKey = parentTerminalHandle - ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) - : undefined + const parentPaneKey = + (parentTerminalHandle ? this.getPaneKeyForTerminalHandle(parentTerminalHandle) : undefined) ?? + storedRun?.coordinator_pane_key return { taskId: dispatch.task_id, @@ -29497,8 +29497,10 @@ export class OrcaRuntimeService { ...(display.displayName ? { displayName: display.displayName } : {}), ...(parentTerminalHandle ? { parentTerminalHandle } : {}), ...(parentPaneKey ? { parentPaneKey } : {}), - ...(activeRun?.coordinator_handle ? { coordinatorHandle: activeRun.coordinator_handle } : {}), - ...(activeRun?.id ? { orchestrationRunId: activeRun.id } : {}) + ...(orchestrationRun?.coordinator_handle + ? { coordinatorHandle: orchestrationRun.coordinator_handle } + : {}), + ...(orchestrationRun?.id ? { orchestrationRunId: orchestrationRun.id } : {}) } } diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 512467eb724..1515dc00ef5 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -197,6 +197,34 @@ describe('configureDevUserDataPath', () => { expect(app.setPath).toHaveBeenCalledWith('userData', '/tmp/orca-dev-repro') }) + it('uses the packaged profile only with the explicit production-profile flag', async () => { + const { app } = await import('electron') + const { configureDevUserDataPath, devParentShutdownOwnsDaemon } = + await import('./configure-process') + const originalProductionProfile = process.env.ORCA_DEV_PRODUCTION_PROFILE + const originalOverride = process.env.ORCA_DEV_USER_DATA_PATH + process.env.ORCA_DEV_PRODUCTION_PROFILE = '1' + process.env.ORCA_DEV_USER_DATA_PATH = '/tmp/ignored-dev-profile' + + try { + configureDevUserDataPath(true) + expect(devParentShutdownOwnsDaemon(true)).toBe(false) + } finally { + restoreEnv('ORCA_DEV_PRODUCTION_PROFILE', originalProductionProfile) + restoreEnv('ORCA_DEV_USER_DATA_PATH', originalOverride) + } + + expect(app.setPath).toHaveBeenCalledWith('userData', join('/tmp/app-data', 'orca')) + }) + + it('owns the detached daemon for ordinary dev profiles only', async () => { + const { devParentShutdownOwnsDaemon } = await import('./configure-process') + + expect(devParentShutdownOwnsDaemon(true, {})).toBe(true) + expect(devParentShutdownOwnsDaemon(true, { ORCA_DEV_PRODUCTION_PROFILE: '1' })).toBe(false) + expect(devParentShutdownOwnsDaemon(false, {})).toBe(false) + }) + it('moves dev runs onto an orca-dev userData path', async () => { const { app } = await import('electron') const { configureDevUserDataPath } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 292f6195258..50f6e27b9ad 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -7,6 +7,7 @@ import { getMainE2EConfig } from '../e2e-config' const DEV_PARENT_SHUTDOWN_GRACE_MS = 3000 const HTTP1_COMPATIBILITY_ENV_VAR = 'ORCA_DISABLE_HTTP2' +const DEV_PRODUCTION_PROFILE_ENV_VAR = 'ORCA_DEV_PRODUCTION_PROFILE' const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']) const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']) let devParentShutdownRequested = false @@ -161,6 +162,10 @@ export function configureDevUserDataPath(isDev: boolean): void { if (!isDev) { return } + if (isProductionProfileDevMode(isDev)) { + app.setPath('userData', join(app.getPath('appData'), 'orca')) + return + } const overrideUserDataPath = process.env.ORCA_DEV_USER_DATA_PATH if (overrideUserDataPath) { // Why: automated repros need an isolated profile so the dev's persisted tabs/worktrees don't skew startup and hide window bugs. @@ -171,6 +176,20 @@ export function configureDevUserDataPath(isDev: boolean): void { app.setPath('userData', join(app.getPath('appData'), 'orca-dev')) } +export function isProductionProfileDevMode( + isDev: boolean, + env: NodeJS.ProcessEnv = process.env +): boolean { + return isDev && env[DEV_PRODUCTION_PROFILE_ENV_VAR] === '1' +} + +export function devParentShutdownOwnsDaemon( + isDev: boolean, + env: NodeJS.ProcessEnv = process.env +): boolean { + return isDev && !isProductionProfileDevMode(isDev, env) +} + function areSameE2EHomePath(left: string, right: string): boolean { const normalizedLeft = resolve(left) const normalizedRight = resolve(right) diff --git a/src/main/startup/dev-instance-identity.test.ts b/src/main/startup/dev-instance-identity.test.ts index 2b06e85dda4..f5535e3ac18 100644 --- a/src/main/startup/dev-instance-identity.test.ts +++ b/src/main/startup/dev-instance-identity.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { getDevInstanceIdentity } from './dev-instance-identity' +import { + getDevInstanceIdentity, + resolveDevAgentHookEndpointNamespace +} from './dev-instance-identity' describe('dev-instance-identity', () => { it('keeps packaged identity stable', () => { @@ -67,4 +70,16 @@ describe('dev-instance-identity', () => { expect(identity.name).toBe('Orca: feature/other') expect(identity.dockBadgeLabel).toBeNull() }) + + it('shares the stable hook endpoint only in explicit production-profile dev mode', () => { + const identity = getDevInstanceIdentity(true, { + ORCA_DEV_REPO_ROOT: '/repo/worktrees/fleet-dashboard' + }) + + expect(resolveDevAgentHookEndpointNamespace(identity, false)).toBe(identity.appUserModelId) + expect(resolveDevAgentHookEndpointNamespace(identity, true)).toBeUndefined() + expect(resolveDevAgentHookEndpointNamespace(getDevInstanceIdentity(false, {}), false)).toBe( + undefined + ) + }) }) diff --git a/src/main/startup/dev-instance-identity.ts b/src/main/startup/dev-instance-identity.ts index 6d13541ea1f..da7c4ce4cc5 100644 --- a/src/main/startup/dev-instance-identity.ts +++ b/src/main/startup/dev-instance-identity.ts @@ -15,6 +15,13 @@ export type DevInstanceIdentity = AppIdentity & { appName: string } +export function resolveDevAgentHookEndpointNamespace( + identity: DevInstanceIdentity, + productionProfileMode: boolean +): string | undefined { + return identity.isDev && !productionProfileMode ? identity.appUserModelId : undefined +} + function cleanEnvValue(value: string | undefined): string | null { const trimmed = value?.replace(/\s+/g, ' ').trim() if (!trimmed) { diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index 2146563373d..dae2016e204 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -283,6 +283,54 @@ describe('run-electron-vite-dev', () => { } ) + it.skipIf(process.platform === 'win32')( + 'prepares CLI wrappers in the packaged profile for explicit production-profile dev', + async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-production-profile-')) + const configRoot = join(tempDir, '.config') + const userDataPath = + process.platform === 'darwin' + ? join(tempDir, 'Library', 'Application Support', 'orca') + : join(configRoot, 'orca') + const pidFile = join(tempDir, 'grandchild.pid') + const envFile = join(tempDir, 'env.json') + const wrapperPath = resolve('config/scripts/run-electron-vite-dev.mjs') + const fakeCliPath = resolve('src/main/startup/__fixtures__/fake-electron-vite-dev-cli.mjs') + + const wrapper = spawn(process.execPath, [wrapperPath], { + cwd: resolve('.'), + env: devWrapperTestEnv({ + HOME: tempDir, + XDG_CONFIG_HOME: configRoot, + ORCA_DEV_PRODUCTION_PROFILE: '1', + ORCA_ELECTRON_VITE_CLI: fakeCliPath, + ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1', + ORCA_SKIP_DEV_WEB_PREPARE: '1', + ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile, + ORCA_DEV_WRAPPER_TEST_ENV_FILE: envFile + }), + stdio: 'ignore' + }) + + expect(wrapper.pid).toBeTypeOf('number') + processesToCleanUp.add(wrapper.pid!) + + await waitFor(() => { + try { + return readFileSync(envFile, 'utf8').trim().length > 0 + } catch { + return false + } + }) + + const trackedPids = trackPidFile(pidFile) + const devWrapper = readFileSync(join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8') + expect(devWrapper).toContain(userDataPath) + + await stopWrapperAndTrackedPids(wrapper, trackedPids) + } + ) + it('consumes the stable-name flag before forwarding args to electron-vite', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'orca-dev-wrapper-')) const pidFile = join(tempDir, 'grandchild.pid') diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 368c4df4ff7..a4f596e2ea4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,7 +10,11 @@ import type { } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { BrowserFindSource } from '../shared/browser-find-source' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardSnapshot, + DashboardRevealAgentArgs, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2467,10 +2471,12 @@ export type PreloadApi = { onSnapshotRequested: (callback: () => void) => () => void onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void onAckAgent: (callback: (paneKey: string) => void) => () => void + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void requestSnapshot: () => Promise onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void revealAgent: (args: DashboardRevealAgentArgs) => Promise ackAgent: (paneKey: string) => Promise + spawnAgent: (args: DashboardSpawnAgentArgs) => Promise } terminalPreview: { connect: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index e2ca5656d00..0dbd4d97633 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,11 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardSnapshot, + DashboardRevealAgentArgs, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2291,6 +2295,12 @@ const api = { ipcRenderer.on('ui:ackDashboardAgent', listener) return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, // ── Consumer side (pop-out window) ─────────────────────────────────── requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), @@ -2303,7 +2313,9 @@ const api = { revealAgent: (args: DashboardRevealAgentArgs): Promise => ipcRenderer.invoke('dashboardPopout:revealAgent', args), ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }) + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args) }, terminalPreview: { diff --git a/src/renderer/src/components/dashboard-popout/ActivityLanes.test.tsx b/src/renderer/src/components/dashboard-popout/ActivityLanes.test.tsx new file mode 100644 index 00000000000..09933ae5efc --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/ActivityLanes.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { ActivityLanes } from './ActivityLanes' + +const NOW = 12 * 60 * 60 * 1_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'parent', + ptyId: 'pty-parent', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Implement activity lanes', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'dashboard', + conversationName: 'Lane parent', + startedAt: NOW - 60 * 60 * 1_000, + finishedAt: null, + stateChangedAt: NOW - 30 * 60 * 1_000, + lastResponseAt: NOW - 5 * 60 * 1_000, + unseen: false, + ...overrides + } +} + +afterEach(cleanup) + +describe('ActivityLanes', () => { + it('renders lineage across worktrees with fixed icons and the canonical working spinner', () => { + const onOpenTerminal = vi.fn() + const parent = card() + const child = card({ + paneKey: 'child', + parentPaneKey: parent.paneKey, + worktreeId: 'worktree-2', + worktreeName: 'child-worktree', + conversationName: 'Lane child', + startedAt: NOW - 30 * 60 * 1_000 + }) + + const { container } = render( + + ) + + expect(screen.getByText('dashboard')).toBeInTheDocument() + expect(screen.getByText(/child-worktree/)).toBeInTheDocument() + expect(screen.getAllByText(/5m since response/)).toHaveLength(2) + expect(container.querySelectorAll('.activity-lane-agent-icon')).toHaveLength(2) + expect(container.querySelector('[data-agent-spinner]')).toBeInTheDocument() + expect(container.querySelector('.activity-lane-lineage path')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Lane child,/ })) + expect(onOpenTerminal).toHaveBeenCalledWith(child, 'right') + }) + + it('reveals stale completed sessions with Show older', () => { + const recent = card({ + paneKey: 'recent', + bucket: 'done', + dotState: 'done', + conversationName: 'Recent result', + finishedAt: NOW - 30 * 60 * 1_000, + lastResponseAt: NOW - 30 * 60 * 1_000 + }) + const old = card({ + paneKey: 'old', + bucket: 'done', + dotState: 'done', + conversationName: 'Older result', + finishedAt: NOW - 3 * 60 * 60 * 1_000, + lastResponseAt: NOW - 3 * 60 * 60 * 1_000 + }) + render() + + expect(screen.getByRole('button', { name: /Recent result,/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Older result,/ })).not.toBeInTheDocument() + expect(screen.getByText(/1 shown · 1 older hidden/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Show older' })) + expect(screen.getByRole('button', { name: /Older result,/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Hide older' })).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/ActivityLanes.tsx b/src/renderer/src/components/dashboard-popout/ActivityLanes.tsx new file mode 100644 index 00000000000..663c43142a4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/ActivityLanes.tsx @@ -0,0 +1,406 @@ +import { useMemo, useState } from 'react' +import { Search } from 'lucide-react' +import { AgentIcon } from '@/lib/agent-catalog' +import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + activityDurationMs, + activityTimelineBounds, + buildActivityLanes, + filterActivityLaneCards, + type ActivityLane, + type ActivityLaneRecentMinutes, + type ActivityLaneStatusFilter +} from './activity-lane-model' +import './activity-lanes.css' + +type ActivityLanesProps = { + cards: DashboardCard[] + now: number + className?: string + selectedPaneKey?: string | null + onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void +} + +const LANE_ROW_HEIGHT = 38 +const LANE_LINE_Y = 26 + +function agentName(card: DashboardCard): string { + return card.conversationName ?? formatAgentTypeLabel(card.agentType) +} + +function formatCompactDuration(durationMs: number): string { + const minutes = Math.max(1, Math.round(durationMs / 60_000)) + if (minutes < 60) { + return `${minutes}m` + } + const hours = Math.floor(minutes / 60) + const remainder = minutes % 60 + if (hours < 24) { + return remainder ? `${hours}h ${remainder}m` : `${hours}h` + } + const days = Math.floor(hours / 24) + return `${days}d ${hours % 24}h` +} + +function formatResponseAge(card: DashboardCard, now: number): string { + if (!card.lastResponseAt || card.lastResponseAt <= 0) { + return translate('dashboardPopout.lanes.responseUnknown', 'response time unavailable') + } + const seconds = Math.max(0, Math.floor((now - card.lastResponseAt) / 1_000)) + if (seconds < 60) { + return translate('dashboardPopout.lanes.responseNow', 'response just now') + } + const minutes = Math.floor(seconds / 60) + if (minutes < 60) { + return translate('dashboardPopout.lanes.responseMinutes', '{{count}}m since response', { + count: minutes + }) + } + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return translate('dashboardPopout.lanes.responseHours', '{{count}}h since response', { + count: hours + }) + } + return translate('dashboardPopout.lanes.responseDays', '{{count}}d since response', { + count: Math.floor(hours / 24) + }) +} + +function formatTick( + timestamp: number, + recentMinutes: ActivityLaneRecentMinutes, + now: number +): string { + if (timestamp === now) { + return translate('dashboardPopout.lanes.now', 'Now') + } + return new Date(timestamp).toLocaleTimeString([], { + hour: 'numeric', + ...(recentMinutes <= 120 ? { minute: '2-digit' } : {}) + }) +} + +function LaneStateMarker({ card }: { card: DashboardCard }): React.JSX.Element { + if (card.dotState === 'working') { + return + } + return +} + +function ActivityLaneRow({ + card, + lane, + now, + recentMinutes, + selected, + onOpenTerminal +}: { + card: DashboardCard + lane: ActivityLane + now: number + recentMinutes: ActivityLaneRecentMinutes + selected: boolean + onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void +}): React.JSX.Element { + const bounds = activityTimelineBounds(card, now, recentMinutes) + const labelLeft = bounds.startPercent > 70 + const isChild = Boolean( + card.parentPaneKey && lane.cards.some((item) => item.paneKey === card.parentPaneKey) + ) + const differentWorktree = card.worktreeName !== lane.worktreeName + const responseTickVisible = + bounds.responsePercent !== null && + bounds.responsePercent > bounds.lineStartPercent + 0.6 && + bounds.responsePercent < bounds.endPercent - 0.6 + const style = { + '--lane-start': `${bounds.startPercent}%`, + '--lane-end': `${bounds.endPercent}%`, + '--lane-line-start': `${bounds.lineStartPercent}%`, + '--lane-width': `${bounds.widthPercent}%`, + '--lane-response': `${bounds.responsePercent ?? 0}%` + } as React.CSSProperties + + return ( + + ) +} + +function LaneConnections({ + lane, + now, + recentMinutes +}: { + lane: ActivityLane + now: number + recentMinutes: ActivityLaneRecentMinutes +}): React.JSX.Element | null { + const rowByPaneKey = new Map(lane.cards.map((card, index) => [card.paneKey, index])) + const paths = lane.cards.flatMap((card, index) => { + const parentIndex = card.parentPaneKey ? rowByPaneKey.get(card.parentPaneKey) : undefined + if (parentIndex === undefined) { + return [] + } + const x = activityTimelineBounds(card, now, recentMinutes).startPercent + return [ + + ] + }) + if (paths.length === 0) { + return null + } + return ( + + {paths} + + ) +} + +function ActivityLaneSection({ + lane, + now, + recentMinutes, + selectedPaneKey, + onOpenTerminal +}: { + lane: ActivityLane + now: number + recentMinutes: ActivityLaneRecentMinutes + selectedPaneKey: string | null + onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void +}): React.JSX.Element { + return ( +
    +
    + {lane.worktreeName} + + {lane.repoName} ·{' '} + {translate('dashboardPopout.lanes.agentCount', '{{count}} agents', { + count: lane.cards.length + })} + +
    +
    + + {lane.cards.map((card) => ( + + ))} +
    +
    + ) +} + +function recentMinutesFromValue(value: string): ActivityLaneRecentMinutes { + return value === '30' ? 30 : value === '120' ? 120 : 1_440 +} + +function statusFromValue(value: string): ActivityLaneStatusFilter { + return value === 'attention' || value === 'working' || value === 'done' || value === 'idle' + ? value + : 'all' +} + +export function ActivityLanes({ + cards, + now, + className, + selectedPaneKey = null, + onOpenTerminal +}: ActivityLanesProps): React.JSX.Element { + const [recentMinutes, setRecentMinutes] = useState(1_440) + const [status, setStatus] = useState('all') + const [showOlder, setShowOlder] = useState(false) + const visibleCards = useMemo( + () => filterActivityLaneCards(cards, { now, recentMinutes, status, showOlder }), + [cards, now, recentMinutes, showOlder, status] + ) + const withOlder = useMemo( + () => filterActivityLaneCards(cards, { now, recentMinutes, status, showOlder: true }), + [cards, now, recentMinutes, status] + ) + const lanes = useMemo(() => buildActivityLanes(visibleCards), [visibleCards]) + const hiddenOlderCount = Math.max(0, withOlder.length - visibleCards.length) + const rangeStart = now - recentMinutes * 60_000 + const ticks = Array.from( + { length: 5 }, + (_, index) => rangeStart + ((now - rangeStart) * index) / 4 + ) + + return ( +
    +
    +
    + + {translate('dashboardPopout.lanes.shown', '{{count}} shown', { + count: visibleCards.length + })} + {!showOlder && hiddenOlderCount > 0 + ? ` · ${translate('dashboardPopout.lanes.olderHidden', '{{count}} older hidden', { count: hiddenOlderCount })}` + : ''} + + + + +
    +
    + {translate('dashboardPopout.lanes.axis', 'Worktree · session duration')} +
    + {ticks.map((tick) => ( + + ))} +
    +
    + {lanes.length > 0 ? ( + lanes.map((lane) => ( + + )) + ) : ( +
    + + {translate('dashboardPopout.lanes.empty', 'No matching agents')} + + {translate( + 'dashboardPopout.lanes.emptyHint', + 'Broaden a search or filter, or show older completed sessions.' + )} + +
    + )} +
    +
    + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx index 8158a1b97e1..2ec185d450d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentCellMap.test.tsx @@ -43,7 +43,7 @@ function renderCells(cards: DashboardCard[], onOpenTerminal = vi.fn()): ReturnTy afterEach(cleanup) describe('AgentCellMap', () => { - it('groups fixed-size agents into project and worktree cells', () => { + it('groups agents into repo sections of glass worktree cells', () => { renderCells([ card(), card({ @@ -66,7 +66,10 @@ describe('AgentCellMap', () => { expect(screen.getAllByTestId('agent-cell-worktree')).toHaveLength(2) expect(screen.getByText('Cell map')).toBeInTheDocument() expect(screen.getByText('Mobile cells')).toBeInTheDocument() + expect(screen.getByText('1 worktrees · 2 agents')).toBeInTheDocument() + // Each agent is an orb carrying its own state class. expect(screen.getByRole('button', { name: 'Agent alpha, Working' })).toHaveClass( + 'agent-cell-orb', 'fleet-status-working' ) expect(screen.getByRole('button', { name: 'Agent beta, Done' })).toHaveClass( @@ -74,32 +77,84 @@ describe('AgentCellMap', () => { ) }) - it('opens the shared terminal flow from an agent node', () => { - const onOpenTerminal = vi.fn() - const agent = card() - renderCells([agent], onOpenTerminal) + it('blooms a cell with the worst state its agents are in', () => { + renderCells([ + card(), + card({ paneKey: 'pane-2', bucket: 'attention', dotState: 'waiting' }), + card({ paneKey: 'pane-3', bucket: 'attention', dotState: 'blocked' }) + ]) - fireEvent.click(screen.getByRole('button', { name: 'Agent alpha, Working' })) - expect(onOpenTerminal).toHaveBeenCalledWith(agent, 'right') + expect(screen.getByTestId('agent-cell-worktree')).toHaveAttribute('data-worst', 'blocked') }) - it('shows the most recent response age instead of an agent count', () => { + it('writes words only for an agent that needs the user', () => { renderCells([ + card({ task: 'Silent work' }), card({ - paneKey: 'pane-1', - bucket: 'done', - dotState: 'done', - finishedAt: NOW - 10 * 60_000, - stateChangedAt: NOW - 10 * 60_000 - }), - card({ - paneKey: 'pane-2', - conversationName: 'Agent beta', - startedAt: NOW - 30 * 60_000, - stateChangedAt: NOW - 2 * 60_000 + paneKey: 'pane-ask', + conversationName: 'Agent ask', + bucket: 'attention', + dotState: 'waiting', + askSummary: 'Should I force-push the rebase?', + stateChangedAt: NOW - 5 * 60_000 }) ]) - expect(screen.getByText('2m')).toBeInTheDocument() + expect(screen.getByText('Should I force-push the rebase?')).toBeInTheDocument() + expect(screen.getByText('5m')).toBeInTheDocument() + // The working agent contributes an orb, not a line of text. + expect(screen.queryByText('Silent work')).not.toBeInTheDocument() + }) + + it('falls back to the last agent message when no ask summary was captured', () => { + renderCells([ + card({ + bucket: 'attention', + dotState: 'waiting', + lastAgentMessage: 'Which migration should run first?' + }) + ]) + + expect(screen.getByText('Which migration should run first?')).toBeInTheDocument() + }) + + it('opens the responder from an orb and from the ask line', () => { + const onOpenTerminal = vi.fn() + const asking = card({ + bucket: 'attention', + dotState: 'waiting', + askSummary: 'Ready to merge?' + }) + renderCells([asking], onOpenTerminal) + + fireEvent.click(screen.getByRole('button', { name: 'Agent alpha, Waiting for input' })) + expect(onOpenTerminal).toHaveBeenCalledWith(asking, 'right') + + onOpenTerminal.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Open the question from Agent alpha' })) + expect(onOpenTerminal).toHaveBeenCalledWith(asking, 'right') + }) + + it('rings the selected agent orb', () => { + render( + + + + ) + + expect(screen.getByRole('button', { name: 'Agent beta, Working' })).toHaveClass('is-selected') + expect(screen.getByRole('button', { name: 'Agent alpha, Working' })).not.toHaveClass( + 'is-selected' + ) + }) + + it('keeps its own scroll container on the sleek scrollbar', () => { + const { container } = renderCells([card()]) + expect(container.querySelector('.agent-cell-map')).toHaveClass('scrollbar-sleek') }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentCellMap.tsx b/src/renderer/src/components/dashboard-popout/AgentCellMap.tsx index 30fd8544833..d3ca281ea14 100644 --- a/src/renderer/src/components/dashboard-popout/AgentCellMap.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentCellMap.tsx @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' -import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { agentStateLabel } from '@/components/AgentStateDot' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' -import { AgentIcon } from '@/lib/agent-catalog' -import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' +import { formatAgentTypeLabel } from '@/lib/agent-status' import { cn } from '@/lib/utils' -import type { DashboardBucket, DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { askLine, buildAgentCellSections, type AgentCellWorktree } from './agent-cell-model' import './agent-cell-map.css' type AgentCellMapProps = { @@ -16,38 +16,14 @@ type AgentCellMapProps = { onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void } -type WorktreeCell = { - id: string - name: string - cards: DashboardCard[] - lastResponseAt: number +/** Which half of the window the click landed in — the panel opens opposite it. */ +function panelSideFor(element: HTMLElement): 'left' | 'right' { + const bounds = element.getBoundingClientRect() + return bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left' } -type ProjectCell = { - id: string - name: string - worktrees: WorktreeCell[] - agentCount: number -} - -const BUCKET_PRIORITY: Record = { - attention: 0, - working: 1, - done: 2, - idle: 3 -} - -function compareCards(a: DashboardCard, b: DashboardCard): number { - return ( - BUCKET_PRIORITY[a.bucket] - BUCKET_PRIORITY[b.bucket] || b.stateChangedAt - a.stateChangedAt - ) -} - -function responseTimestamp(card: DashboardCard): number { - if (card.finishedAt && card.finishedAt > 0) { - return card.finishedAt - } - return card.stateChangedAt > 0 ? card.stateChangedAt : card.startedAt +function agentName(card: DashboardCard): string { + return card.conversationName ?? formatAgentTypeLabel(card.agentType) } function formatResponseAge(timestamp: number, now: number): string { @@ -71,61 +47,8 @@ function formatResponseAge(timestamp: number, now: number): string { }) } -function buildProjectCells(cards: DashboardCard[]): ProjectCell[] { - const projects = new Map() - for (const card of cards) { - projects.set(card.repoId, [...(projects.get(card.repoId) ?? []), card]) - } - return [...projects.entries()] - .map(([id, projectCards]) => { - const worktrees = new Map() - for (const card of projectCards) { - worktrees.set(card.worktreeId, [...(worktrees.get(card.worktreeId) ?? []), card]) - } - return { - id, - name: projectCards[0]?.repoName ?? id, - agentCount: projectCards.length, - worktrees: [...worktrees.entries()] - .map(([worktreeId, worktreeCards]) => ({ - id: worktreeId, - name: worktreeCards[0]?.worktreeName ?? worktreeId, - cards: [...worktreeCards].sort(compareCards), - lastResponseAt: Math.max(...worktreeCards.map(responseTimestamp)) - })) - .sort( - (a, b) => - compareCards(a.cards[0], b.cards[0]) || - b.cards.length - a.cards.length || - a.name.localeCompare(b.name) - ) - } - }) - .sort((a, b) => b.agentCount - a.agentCount || a.name.localeCompare(b.name)) -} - -function worktreeTone(cards: DashboardCard[]): string { - if (cards.some((card) => card.bucket === 'attention')) { - return 'has-attention' - } - if (cards.some((card) => card.bucket === 'working')) { - return 'has-working' - } - return cards.every((card) => card.dotState === 'done') ? 'has-done' : '' -} - -function worktreeSize(agentCount: number): string { - if (agentCount >= 9) { - return 'is-large' - } - return agentCount >= 5 ? 'is-medium' : '' -} - -function agentName(card: DashboardCard): string { - return card.conversationName ?? formatAgentTypeLabel(card.agentType) -} - -function AgentCellNode({ +/** An agent as light: the orb carries the state, the tooltip carries the words. */ +function AgentCellOrb({ card, selected, setNodeRef, @@ -142,23 +65,17 @@ function AgentCellNode({ + onClick={(event) => onOpenTerminal(card, panelSideFor(event.currentTarget))} + /> - + {agentName(card)} {agentStateLabel(card.dotState)} @@ -166,6 +83,100 @@ function AgentCellNode({ ) } +function AgentCellAsk({ + card, + text, + now, + onOpenTerminal +}: { + card: DashboardCard + text: string + now: number + onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void +}): React.JSX.Element { + return ( + + ) +} + +function AgentCellWorktreeCard({ + worktree, + now, + selectedPaneKey, + setNodeRef, + onOpenTerminal +}: { + worktree: AgentCellWorktree + now: number + selectedPaneKey: string | null + setNodeRef: (paneKey: string, node: HTMLButtonElement | null) => void + onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void +}): React.JSX.Element { + const asks = worktree.cards.flatMap((card) => { + const text = askLine(card) + return text ? [{ card, text }] : [] + }) + return ( +
    +
    +

    {worktree.name}

    + + {worktree.cards.length} + +
    +
    + {worktree.cards.map((card) => ( + setNodeRef(card.paneKey, node)} + onOpenTerminal={onOpenTerminal} + /> + ))} +
    + {asks.map(({ card, text }) => ( + + ))} +
    + ) +} + +/** + * The fleet as glass: one section per repo, one cell per worktree, agents as + * orbs. Words appear only where an agent is waiting on the user. + */ export function AgentCellMap({ cards, now, @@ -173,7 +184,7 @@ export function AgentCellMap({ selectedPaneKey = null, onOpenTerminal }: AgentCellMapProps): React.JSX.Element { - const projects = useMemo(() => buildProjectCells(cards), [cards]) + const sections = useMemo(() => buildAgentCellSections(cards), [cards]) const nodeRefs = useRef(new Map()) const setNodeRef = useCallback((paneKey: string, node: HTMLButtonElement | null) => { if (node) { @@ -195,7 +206,7 @@ export function AgentCellMap({ ?.scrollIntoView?.({ behavior, block: 'center', inline: 'center' }) }, [selectedPaneKey]) - if (projects.length === 0) { + if (sections.length === 0) { return (
    - {translate('dashboardPopout.rings.empty', 'No agents match the current filters.')} + {translate('dashboardPopout.map.empty', 'No agents match the current filters.')}
    ) } return (
    -
    - {projects.map((project) => ( -
    -
    -

    {project.name}

    - - {translate( - 'dashboardPopout.rings.projectCount', - '{{agents}} agents · {{workspaces}} workspaces', - { - agents: project.agentCount, - workspaces: project.worktrees.length - } - )} - -
    -
    - {project.worktrees.map((worktree) => ( -
    card.paneKey === selectedPaneKey) && 'is-selected' - )} - data-testid="agent-cell-worktree" - > -
    -

    - {worktree.name} -

    - - {formatResponseAge(worktree.lastResponseAt, now)} - -
    -
    - {worktree.cards.map((card) => ( - setNodeRef(card.paneKey, node)} - onOpenTerminal={onOpenTerminal} - /> - ))} -
    -
    - ))} -
    -
    - ))} -
    + {sections.map((section) => ( +
    +
    +

    {section.name}

    + + {translate( + 'dashboardPopout.cells.sectionMeta', + '{{worktrees}} worktrees · {{agents}} agents', + { worktrees: section.worktrees.length, agents: section.agentCount } + )} + +
    +
    + {section.worktrees.map((worktree) => ( + + ))} +
    +
    + ))}
    ) } diff --git a/src/renderer/src/components/dashboard-popout/AgentChatPanel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentChatPanel.test.tsx new file mode 100644 index 00000000000..7c56d78df91 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentChatPanel.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { AgentChatPanel } from './AgentChatPanel' + +// The real transcript list drives markdown, autoscroll, and IPC reads; the panel +// contract under test is what it feeds them, so both are stubbed. +const liveSession = vi.hoisted(() => vi.fn()) +vi.mock('@/components/native-chat/use-native-chat-live-session', () => ({ + useNativeChatLiveSession: (args: unknown) => liveSession(args) +})) +vi.mock('@/components/native-chat/NativeChatMessageList', () => ({ + NativeChatMessageList: ({ + session, + isWorking + }: { + session: { messages: { id: string }[] } + isWorking: boolean + }) => ( +
    + ) +})) + +const terminalInput = vi.fn(async () => true) + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'tab-1:leaf-1', + ptyId: 'pty-1', + agentType: 'claude', + bucket: 'working', + dotState: 'working', + task: 'Ship the chat panel', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'chat-panel', + conversationName: 'Chat agent', + hostKind: 'local', + sessionId: 'session-1', + transcriptPath: '/tmp/session-1.jsonl', + startedAt: 1, + finishedAt: null, + stateChangedAt: 1, + unseen: false, + ...overrides + } +} + +beforeEach(() => { + liveSession.mockReturnValue({ + agent: 'claude', + sessionId: 'session-1', + messages: [{ id: 'm1', role: 'assistant' }], + status: 'idle', + hasMore: false, + loadingEarlier: false, + loadEarlier: vi.fn(), + readPhase: 'ready' + }) + ;(window as unknown as { api: unknown }).api = { terminalPreview: { input: terminalInput } } +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('AgentChatPanel', () => { + it('reads the real transcript for the card session and heads it with the agent', () => { + render() + + expect(liveSession).toHaveBeenCalledWith({ + paneKey: 'tab-1:leaf-1', + agent: 'claude', + sessionId: 'session-1', + transcriptPath: '/tmp/session-1.jsonl', + runtimeEnvironmentId: null + }) + expect(screen.getByTestId('native-chat-list')).toHaveAttribute('data-messages', '1') + // The pop-out has no live store, so the snapshot bucket is the working signal. + expect(screen.getByTestId('native-chat-list')).toHaveAttribute('data-working', 'true') + expect(screen.getByRole('heading', { name: 'Chat agent' })).toBeInTheDocument() + expect(screen.getByText('Orca / chat-panel')).toBeInTheDocument() + }) + + it('sends the body and the submit as separate pty writes', async () => { + render() + + fireEvent.change(screen.getByLabelText('Reply to this agent'), { + target: { value: 'ship it' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + + await waitFor(() => expect(terminalInput).toHaveBeenCalledTimes(2)) + expect(terminalInput).toHaveBeenNthCalledWith(1, 'pty-1', 'ship it') + expect(terminalInput).toHaveBeenNthCalledWith(2, 'pty-1', '\r') + await waitFor(() => expect(screen.getByLabelText('Reply to this agent')).toHaveValue('')) + }) + + it('reports a refused write instead of pretending the reply landed', async () => { + terminalInput.mockResolvedValueOnce(false) + render() + + fireEvent.change(screen.getByLabelText('Reply to this agent'), { target: { value: 'hello' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + + expect(await screen.findByText(/did not reach the agent/)).toBeInTheDocument() + expect(terminalInput).toHaveBeenCalledTimes(1) + // The draft survives so the user can retry or copy it out. + expect(screen.getByLabelText('Reply to this agent')).toHaveValue('hello') + }) + + it('withholds the composer when the agent has no live pane', () => { + render() + + expect( + screen.getByText('No live pane, so a reply cannot reach this agent.') + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Send' })).not.toBeInTheDocument() + }) + + it('degrades to the snapshot text when the card has no session', () => { + render( + + ) + + expect(liveSession).not.toHaveBeenCalled() + expect(screen.queryByTestId('native-chat-list')).not.toBeInTheDocument() + expect(screen.getByText(/has not reported a session yet/)).toBeInTheDocument() + expect(screen.getByText('Approve the migration?')).toBeInTheDocument() + expect(screen.getByText('I paused before the migration.')).toBeInTheDocument() + // Replying still works — only the transcript read is unavailable. + expect(screen.getByRole('button', { name: 'Send' })).toBeInTheDocument() + }) + + it('degrades on a remote host, whose transcript path is not local', () => { + render() + + expect(liveSession).not.toHaveBeenCalled() + expect(screen.getByText(/runs on a remote host/)).toBeInTheDocument() + }) + + it('escalates to the terminal and closes on request', () => { + const onClose = vi.fn() + const onOpenTerminal = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Open terminal' })) + expect(onOpenTerminal).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'Close chat' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentChatPanel.tsx b/src/renderer/src/components/dashboard-popout/AgentChatPanel.tsx new file mode 100644 index 00000000000..53b070c41cc --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentChatPanel.tsx @@ -0,0 +1,272 @@ +import { useId, useState } from 'react' +import { SquareTerminal, XIcon } from 'lucide-react' +import { AgentStateDot } from '@/components/AgentStateDot' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { NativeChatMessageList } from '@/components/native-chat/NativeChatMessageList' +import { + buildNativeChatPasteBytes, + NATIVE_CHAT_SUBMIT +} from '@/components/native-chat/native-chat-send' +import { useNativeChatLiveSession } from '@/components/native-chat/use-native-chat-live-session' +import { translate } from '@/i18n/i18n' +import { formatAgentTypeLabel } from '@/lib/agent-status' +import { cn } from '@/lib/utils' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { resolveAgentChatPanelMode, type AgentChatPanelMode } from './agent-chat-panel-mode' + +type AgentChatPanelProps = { + card: DashboardCard + onClose: () => void + onOpenTerminal?: () => void + className?: string +} + +function agentName(card: DashboardCard): string { + return card.conversationName ?? (card.task.trim() || formatAgentTypeLabel(card.agentType)) +} + +/** Live transcript. Its own component so the session hook is never called for a + * card that has nothing to read. */ +function AgentChatTranscript({ + card, + sessionId, + transcriptPath +}: { + card: DashboardCard + sessionId: string + transcriptPath: string | null +}): React.JSX.Element { + // Bucket, not the hook status: the store backing useNativeChatHookStatus is + // empty in the pop-out renderer, so the snapshot is the only live signal here. + const session = useNativeChatLiveSession({ + paneKey: card.paneKey, + agent: card.agentType, + sessionId, + transcriptPath, + runtimeEnvironmentId: null + }) + if (session.messages.length === 0 && session.readPhase !== 'loading') { + return ( +

    + {translate('dashboardPopout.chat.empty', 'No messages in this transcript yet.')} +

    + ) + } + return ( +
    + +
    + ) +} + +/** What the panel shows when it cannot read the real transcript: the snapshot's + * own last message, plus the escalation to the terminal. */ +function AgentChatFallback({ + card, + reason +}: { + card: DashboardCard + reason: 'no-session' | 'remote-host' +}): React.JSX.Element { + return ( +
    +

    + {reason === 'remote-host' + ? translate( + 'dashboardPopout.chat.remoteHost', + 'This agent runs on a remote host, so its transcript is not readable here.' + ) + : translate( + 'dashboardPopout.chat.noSession', + 'This agent has not reported a session yet, so there is no transcript to read.' + )} +

    + {card.askSummary ? ( +
    +

    + {translate('dashboardPopout.chat.pendingQuestion', 'Pending question')} +

    +

    {card.askSummary}

    +
    + ) : null} + {card.lastAgentMessage ? ( +
    +

    + {translate('dashboardPopout.chat.lastMessage', 'Last message')} +

    +

    {card.lastAgentMessage}

    +
    + ) : null} +
    + ) +} + +function AgentChatComposer({ card }: { card: DashboardCard }): React.JSX.Element { + const [draft, setDraft] = useState('') + const [sending, setSending] = useState(false) + const [failed, setFailed] = useState(false) + const ptyId = card.ptyId + + if (ptyId === null) { + return ( +

    + {translate( + 'dashboardPopout.chat.noPane', + 'No live pane, so a reply cannot reach this agent.' + )} +

    + ) + } + + const send = async (): Promise => { + const message = draft.trim() + if (!message || sending) { + return + } + setSending(true) + setFailed(false) + try { + // The framed body and the Enter must be separate pty writes, or the agent + // TUI treats the submit as part of the pasted text (see native-chat-send). + const bodySent = await window.api.terminalPreview.input( + ptyId, + buildNativeChatPasteBytes(message) + ) + const submitted = + bodySent && (await window.api.terminalPreview.input(ptyId, NATIVE_CHAT_SUBMIT)) + if (!submitted) { + setFailed(true) + return + } + setDraft('') + } catch { + setFailed(true) + } finally { + setSending(false) + } + } + + return ( +
    +
    + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + void send() + } + }} + disabled={sending} + aria-label={translate('dashboardPopout.chat.placeholder', 'Reply to this agent')} + placeholder={translate('dashboardPopout.chat.placeholder', 'Reply to this agent')} + className="h-8 text-xs" + /> + +
    + {failed ? ( +

    + {translate( + 'dashboardPopout.chat.sendFailed', + 'The reply did not reach the agent. Open the terminal to check it.' + )} +

    + ) : null} +
    + ) +} + +function AgentChatBody({ + card, + mode +}: { + card: DashboardCard + mode: AgentChatPanelMode +}): React.JSX.Element { + if (mode.kind === 'degraded') { + return + } + return ( + + ) +} + +/** + * Respond surface for one agent, beside the fleet view: the agent's real native + * chat transcript plus a composer that writes to its live pane. Degrades to the + * snapshot's own last message when the transcript is unreachable from here. + */ +export function AgentChatPanel({ + card, + onClose, + onOpenTerminal, + className +}: AgentChatPanelProps): React.JSX.Element { + const titleId = useId() + const mode = resolveAgentChatPanelMode(card) + + return ( +
    +
    + + +

    + {agentName(card)} +

    + + {card.repoName} / {card.worktreeName} + +
    + {onOpenTerminal ? ( + + ) : null} + +
    + + +
    + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardViewPane.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardViewPane.tsx new file mode 100644 index 00000000000..b91c3e503b9 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardViewPane.tsx @@ -0,0 +1,116 @@ +import { cn } from '@/lib/utils' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { AgentDashboardAgentViewMode } from '../../../../shared/types' +import { ActivityLanes } from './ActivityLanes' +import { AgentCellMap } from './AgentCellMap' +import { AgentMap } from './AgentMap' +import { AgentTerminalPanel, type AgentRevealArgs } from './AgentTerminalDialog' +import type { AgentDashboardView } from './agent-dashboard-view' + +/** Which surface the selected agent opens in. */ +export type AgentDashboardPanelMode = AgentDashboardAgentViewMode + +type AgentDashboardViewPaneProps = { + view: Exclude + cards: DashboardCard[] + now: number + selectedCard: DashboardCard | null + panelMode: AgentDashboardPanelMode + panelSide: 'left' | 'right' + pinnedPaneKeys: ReadonlySet + reviewedPaneKeys: ReadonlySet + launchableAgentsByWorktreeId: Record + onMarkReviewed: (cards: DashboardCard[]) => void + onTogglePinned: (card: DashboardCard) => void + onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void + onSpawnAgent: (args: DashboardSpawnAgentArgs) => void + onPanelOpenChange: (open: boolean) => void + onRevealAgent: (args: AgentRevealArgs) => void +} + +/** Width the fleet view collapses to while a side panel is open. Map and + * cells read as maps at any size; lanes need their timeline axis. */ +function collapsedClassName(view: AgentDashboardViewPaneProps['view']): string { + const width = view === 'lanes' ? 'w-[clamp(22rem,48vw,46rem)]' : 'w-[clamp(14rem,28vw,22rem)]' + return `${width} flex-none transition-[width] duration-200 motion-reduce:transition-none` +} + +/** The non-board fleet views and the side panel they open, sharing one flex row + * whose direction follows the click that opened the panel. */ +export function AgentDashboardViewPane({ + view, + cards, + now, + selectedCard, + panelMode, + panelSide, + pinnedPaneKeys, + reviewedPaneKeys, + launchableAgentsByWorktreeId, + onMarkReviewed, + onTogglePinned, + onOpenTerminal, + onSpawnAgent, + onPanelOpenChange, + onRevealAgent +}: AgentDashboardViewPaneProps): React.JSX.Element { + const terminalCard = panelMode === 'terminal' ? selectedCard : null + const collapsed = terminalCard ? collapsedClassName(view) : undefined + + return ( +
    + {view === 'map' ? ( + + ) : view === 'cells' ? ( + + ) : ( + + )} + {terminalCard ? ( + onMarkReviewed([card])} + onTogglePinned={onTogglePinned} + className={cn( + panelSide === 'right' ? 'ml-0' : 'mr-0', + 'animate-in fade-in-0 duration-200 motion-reduce:animate-none', + panelSide === 'right' ? 'slide-in-from-right-2' : 'slide-in-from-left-2' + )} + /> + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentDashboardViewSelector.tsx b/src/renderer/src/components/dashboard-popout/AgentDashboardViewSelector.tsx new file mode 100644 index 00000000000..43e91302bfc --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentDashboardViewSelector.tsx @@ -0,0 +1,62 @@ +import { ChartNoAxesGantt, Columns3, Orbit } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { AgentDashboardView } from './agent-dashboard-view' + +// 'cells' is intentionally not offered for now; the view stays reachable via ?view=cells. +const VIEW_OPTIONS: readonly { + id: AgentDashboardView + icon: typeof Columns3 +}[] = [ + { id: 'board', icon: Columns3 }, + { id: 'lanes', icon: ChartNoAxesGantt }, + { id: 'map', icon: Orbit } +] + +function viewLabel(view: AgentDashboardView): string { + switch (view) { + case 'board': + return translate('dashboardPopout.view.board', 'Dashboard') + case 'lanes': + return translate('dashboardPopout.view.lanes', 'Activity Lanes') + case 'map': + return translate('dashboardPopout.view.map', 'Agent Map') + case 'cells': + return translate('dashboardPopout.view.cells', 'Cells') + } +} + +export function AgentDashboardViewSelector({ + view, + onViewChange +}: { + view: AgentDashboardView + onViewChange: (view: AgentDashboardView) => void +}): React.JSX.Element { + return ( +
    + {VIEW_OPTIONS.map((option) => { + const Icon = option.icon + return ( + + ) + })} +
    + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index 8553819af8b..450814ed9de 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -10,6 +10,7 @@ import type { } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { i18n } from '@/i18n/i18n' +import { useAppStore } from '@/store' import { AgentKanbanBoard } from './AgentKanbanBoard' // Stub the card and dialog so the board test stays free of xterm / Radix @@ -111,6 +112,26 @@ vi.mock('./AgentTerminalDialog', () => ({ ) })) +// The chat panel owns real transcript IO; the board only decides when it opens. +vi.mock('./AgentChatPanel', () => ({ + AgentChatPanel: ({ + card, + onClose, + onOpenTerminal + }: { + card: DashboardCard + onClose: () => void + onOpenTerminal?: () => void + }) => ( +
    +
    + ) +})) + function card(overrides: Partial): DashboardCard { return { paneKey: Math.random().toString(36), @@ -153,6 +174,7 @@ describe('AgentKanbanBoard', () => { localStorage.clear() // The board relays seen-acks through the dashboard preload API. ;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } } + useAppStore.setState({ settings: null }) }) afterEach(() => { cleanup() @@ -167,10 +189,14 @@ describe('AgentKanbanBoard', () => { expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Done']) }) - it('keeps board, rings, and cells available as separate views', () => { + it('keeps board, lanes, and map available as separate views', () => { renderBoard([]) - fireEvent.click(screen.getByRole('button', { name: 'Agent Rings' })) + fireEvent.click(screen.getByRole('button', { name: 'Activity Lanes' })) + expect(screen.getByText('Worktree · session duration')).toBeInTheDocument() + expect(screen.getByText('No matching agents')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) expect(screen.getByText('Live containment map')).toBeInTheDocument() expect(screen.getByText('Focus view')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Orchestrate' })).toBeInTheDocument() @@ -179,29 +205,93 @@ describe('AgentKanbanBoard', () => { expect(screen.getByText('Needs You')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Orchestrate' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Cells' })) - expect(screen.getByRole('button', { name: 'Cells' })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByText('No agents match the current filters.')).toBeInTheDocument() + // 'cells' is hidden from the selector for now but stays reachable via initialView. + expect(screen.queryByRole('button', { name: 'Cells' })).not.toBeInTheDocument() }) - it('keeps the selected cell map visible beside its terminal panel', () => { + it('answers a cell-map agent in chat, keeping the map beside it', () => { + useAppStore.setState({ + settings: { experimentalAgentDashboardAgentViewMode: 'native-chat' } as never + }) const agent = card({ paneKey: 'cell-agent', conversationName: 'Cell agent' }) render() fireEvent.click(screen.getByRole('button', { name: 'Cell agent, Working' })) expect(screen.getByTestId('agent-cell-project')).toBeInTheDocument() - expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-pty-id', 'p1') + expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-pane-key', 'cell-agent') expect(screen.getByRole('button', { name: 'Cell agent, Working' })).toHaveAttribute( 'aria-pressed', 'true' ) + expect(screen.queryByTestId('terminal-panel')).not.toBeInTheDocument() + expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument() + }) + + it('escalates a cell-map chat to the live terminal on request', () => { + useAppStore.setState({ + settings: { experimentalAgentDashboardAgentViewMode: 'native-chat' } as never + }) + const agent = card({ paneKey: 'cell-agent', conversationName: 'Cell agent' }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Cell agent, Working' })) + fireEvent.click(screen.getByTestId('chat-panel-open-terminal')) + + expect(screen.queryByTestId('chat-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-pty-id', 'p1') + }) + + it('closes the cell-map chat panel without leaving a selection behind', () => { + useAppStore.setState({ + settings: { experimentalAgentDashboardAgentViewMode: 'native-chat' } as never + }) + const agent = card({ paneKey: 'cell-agent', conversationName: 'Cell agent' }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Cell agent, Working' })) + fireEvent.click(screen.getByTestId('chat-panel-close')) + + expect(screen.queryByTestId('chat-panel')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cell agent, Working' })).toHaveAttribute( + 'aria-pressed', + 'false' + ) + }) + + it('keeps lanes and map on the terminal panel', () => { + const agent = card({ paneKey: 'lane-agent', conversationName: 'Lane agent' }) + render() + + fireEvent.click(screen.getByRole('button', { name: /Lane agent,/ })) + + expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() + expect(screen.queryByTestId('chat-panel')).not.toBeInTheDocument() + }) + + it('keeps the selected activity timeline visible beside its terminal panel', () => { + const agent = card({ + paneKey: 'lane-agent', + conversationName: 'Lane agent', + startedAt: Date.now() - 60_000, + lastResponseAt: Date.now() - 30_000 + }) + render() + + fireEvent.click(screen.getByRole('button', { name: /Lane agent,/ })) + + expect(screen.getByText('Worktree · session duration')).toBeInTheDocument() + expect(screen.getByTestId('terminal-panel')).toHaveAttribute('data-pty-id', 'p1') + expect(screen.getByRole('button', { name: /Lane agent,/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ) expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument() }) it('keeps the selected ring map visible beside its terminal panel', () => { const agent = card({ paneKey: 'ring-agent', conversationName: 'Ring agent' }) - render() + render() fireEvent.click(screen.getByRole('button', { name: /Ring agent/ })) @@ -435,7 +525,7 @@ describe('AgentKanbanBoard', () => { unseen: false }) const view = render( - + ) expect(screen.getByRole('button', { name: /Fresh result/ })).toBeInTheDocument() @@ -446,7 +536,7 @@ describe('AgentKanbanBoard', () => { view.rerender( ) expect(screen.getByRole('button', { name: /Fresh result/ })).toBeInTheDocument() @@ -455,7 +545,7 @@ describe('AgentKanbanBoard', () => { const reopened = render( ) expect(screen.getByRole('button', { name: /Fresh result/ })).toBeInTheDocument() @@ -468,7 +558,7 @@ describe('AgentKanbanBoard', () => { render( ) expect(screen.queryByRole('button', { name: /Fresh result/ })).not.toBeInTheDocument() diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index 5eb6ced1b35..48fa38505a3 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -1,22 +1,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Columns3, LayoutGrid, Orbit, XIcon } from 'lucide-react' +import { XIcon } from 'lucide-react' import { DASHBOARD_BUCKET_ORDER, type DashboardBucket, type DashboardCard, + type DashboardSpawnAgentArgs, type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' import { TooltipProvider } from '@/components/ui/tooltip' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { AgentKanbanCard } from './AgentKanbanCard' import { AgentDashboardToolbar } from './AgentDashboardToolbar' -import { - AgentTerminalDialog, - AgentTerminalPanel, - type AgentRevealArgs -} from './AgentTerminalDialog' +import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog' import { EMPTY_DASHBOARD_FILTERS, filterDashboardCards, @@ -24,13 +21,16 @@ import { } from './agent-board-filtering' import './agent-board-transitions.css' import { translate } from '@/i18n/i18n' -import { Button } from '@/components/ui/button' -import { AgentFleetRings } from './AgentFleetRings' -import { AgentCellMap } from './AgentCellMap' import { DashboardOrchestratorChat } from './DashboardOrchestratorChat' import { useFleetResultDisposition } from './use-fleet-result-disposition' +import { AgentKanbanLineage } from './AgentKanbanLineage' +import { buildAgentKanbanLineage } from './agent-kanban-lineage' +import { AgentDashboardViewPane, type AgentDashboardPanelMode } from './AgentDashboardViewPane' +import { AgentDashboardViewSelector } from './AgentDashboardViewSelector' +import type { AgentDashboardView } from './agent-dashboard-view' +import { AgentNativeChatDrawer } from './AgentNativeChatDrawer' -export type AgentDashboardView = 'rings' | 'board' | 'cells' +export type { AgentDashboardView } from './agent-dashboard-view' /** Ack an agent in the pop-out window: relayed over IPC to the main renderer. * ?. shields dialog-opening from dev-HMR preload skew (renderer updates hot, @@ -46,6 +46,10 @@ function revealAgentViaPopoutRelay(args: AgentRevealArgs): void { void window.api.dashboard.revealAgent?.(args) } +function spawnAgentViaPopoutRelay(args: DashboardSpawnAgentArgs): void { + void window.api.dashboard.spawnAgent?.(args) +} + function bucketLabel(bucket: DashboardBucket): string { switch (bucket) { case 'attention': @@ -80,12 +84,14 @@ function groupByBucket(cards: DashboardCard[]): Record repoIconsByRepoId: Record | undefined now: number onOpenTerminal: (card: DashboardCard) => void @@ -108,15 +114,13 @@ function KanbanColumn({ {translate('dashboardPopout.bucket.empty', 'None')}

    ) : ( - cards.map((card) => ( - - )) + )}
    @@ -135,11 +139,12 @@ type AgentKanbanBoardProps = { /** Focuses the agent's pane. Defaults to the pop-out IPC relay; the in-window * host activates the worktree/pane locally and closes the overlay. */ onRevealAgent?: (args: AgentRevealArgs) => void + /** Starts an agent in a workspace. Defaults to the pop-out IPC relay. */ + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void /** When provided, renders a close control in the header (in-window mode). The * pop-out relies on its native window controls, so it omits this. */ onClose?: () => void - /** Header controls rendered before the close button. The in-window host - * passes its settings menu; the pop-out renderer has no store to drive it. */ + /** Header controls rendered before the close button. */ headerActions?: React.ReactNode } @@ -152,9 +157,13 @@ export function AgentKanbanBoard({ containerClassName = 'h-screen w-screen', onAckAgent = ackAgentViaPopoutRelay, onRevealAgent = revealAgentViaPopoutRelay, + onSpawnAgent = spawnAgentViaPopoutRelay, onClose, headerActions }: AgentKanbanBoardProps): React.JSX.Element { + const agentViewMode = useAppStore( + (state) => state.settings?.experimentalAgentDashboardAgentViewMode ?? 'terminal' + ) const [view, setView] = useState(initialView) const visibleBuckets = useMemo( () => @@ -173,12 +182,17 @@ export function AgentKanbanBoard({ [filters, query, visibleCards] ) const grouped = useMemo(() => groupByBucket(filteredCards), [filteredCards]) + const filteredCardsByPaneKey = useMemo( + () => new Map(filteredCards.map((card) => [card.paneKey, card])), + [filteredCards] + ) const hasRelativeTimestamps = useMemo( () => snapshot.cards.some((card) => (card.finishedAt ?? card.startedAt) > 0), [snapshot.cards] ) const [now, setNow] = useState(() => Date.now()) const [terminalPanelSide, setTerminalPanelSide] = useState<'left' | 'right'>('right') + const [panelMode, setPanelMode] = useState(agentViewMode) const { reviewedPaneKeys, pinnedPaneKeys, acknowledge, markReviewed, togglePinned } = useFleetResultDisposition(snapshot.cards, onAckAgent) @@ -247,12 +261,19 @@ export function AgentKanbanBoard({ }, [acknowledge] ) - const handleOpenAdjacentTerminal = useCallback( - (card: DashboardCard, side: 'left' | 'right') => { - setTerminalPanelSide(side) + const handleOpenDashboardAgent = useCallback( + (card: DashboardCard) => { + setPanelMode(agentViewMode) handleOpenTerminal(card) }, - [handleOpenTerminal] + [agentViewMode, handleOpenTerminal] + ) + const handleOpenAdjacentAgent = useCallback( + (card: DashboardCard, side: 'left' | 'right') => { + setTerminalPanelSide(side) + handleOpenDashboardAgent(card) + }, + [handleOpenDashboardAgent] ) // Watching the open dialog counts as seeing state changes as they happen — // without this, an agent finishing while you watch would re-flag its card. @@ -279,45 +300,7 @@ export function AgentKanbanBoard({ count: visibleCards.length })} -
    - - - -
    + {headerActions || onClose ? (
    {headerActions} @@ -345,58 +328,23 @@ export function AgentKanbanBoard({ searchInputRef={searchInputRef} /> {view !== 'board' ? ( -
    - {view === 'rings' ? ( - - ) : ( - - )} - {dialogCard ? ( - markReviewed([card])} - onTogglePinned={togglePinned} - className={cn( - terminalPanelSide === 'right' ? 'ml-0' : 'mr-0', - 'animate-in fade-in-0 duration-200 motion-reduce:animate-none', - terminalPanelSide === 'right' ? 'slide-in-from-right-2' : 'slide-in-from-left-2' - )} - /> - ) : null} -
    + ) : (
    {/* Auto margins center the capped board and collapse during horizontal overflow. */} @@ -406,21 +354,29 @@ export function AgentKanbanBoard({ key={bucket} bucket={bucket} cards={grouped[bucket]} + cardsByPaneKey={filteredCardsByPaneKey} repoIconsByRepoId={snapshot.repoIconsByRepoId} now={now} - onOpenTerminal={handleOpenTerminal} + onOpenTerminal={handleOpenDashboardAgent} /> ))}
    )} - {view === 'board' ? ( + {view === 'board' && panelMode === 'terminal' ? ( ) : null} + {dialogCard && panelMode === 'native-chat' ? ( + handleDialogOpenChange(false)} + onOpenTerminal={() => setPanelMode('terminal')} + /> + ) : null} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index fbaadfdbb78..c89dd721b06 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -86,6 +86,7 @@ function sameCard(a: DashboardCard, b: DashboardCard): boolean { a.task === b.task && a.lastUserMessage === b.lastUserMessage && a.lastAgentMessage === b.lastAgentMessage && + a.lastResponseAt === b.lastResponseAt && a.repoId === b.repoId && a.worktreeId === b.worktreeId && a.tabId === b.tabId && diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanLineage.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanLineage.test.tsx new file mode 100644 index 00000000000..e21ba3b0c8f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanLineage.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { AgentKanbanLineage } from './AgentKanbanLineage' +import { buildAgentKanbanLineage } from './agent-kanban-lineage' + +vi.mock('./AgentKanbanCard', () => ({ + AgentKanbanCard: ({ card }: { card: DashboardCard }) =>
    {card.worktreeName}
    +})) + +function card( + paneKey: string, + bucket: DashboardCard['bucket'], + parentPaneKey?: string +): DashboardCard { + return { + paneKey, + parentPaneKey, + ptyId: null, + agentType: 'codex', + bucket, + dotState: bucket === 'done' ? 'done' : 'working', + task: paneKey, + repoId: 'repo', + worktreeId: 'worktree', + tabId: 'tab', + leafId: null, + repoName: 'Orca', + worktreeName: paneKey, + startedAt: 0, + finishedAt: null, + stateChangedAt: 0, + unseen: false + } +} + +describe('AgentKanbanLineage', () => { + it('draws same-column children beneath their parent', () => { + const parent = card('parent', 'working') + const child = card('child', 'working', 'parent') + const cards = [parent, child] + + render( + [candidate.paneKey, candidate]))} + repoIconsByRepoId={undefined} + now={0} + onOpenTerminal={vi.fn()} + /> + ) + + expect(screen.getByText('child').closest('[data-lineage-depth]')).toHaveAttribute( + 'data-lineage-depth', + '1' + ) + }) + + it('links a child to a parent in another status column', () => { + const parent = card('parent', 'working') + const child = card('child', 'done', 'parent') + + render( + + ) + + expect(screen.getByText('Spawned by parent')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanLineage.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanLineage.tsx new file mode 100644 index 00000000000..b169131a1f3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanLineage.tsx @@ -0,0 +1,85 @@ +import { GitBranch } from 'lucide-react' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import { translate } from '@/i18n/i18n' +import { AgentKanbanCard } from './AgentKanbanCard' +import type { AgentKanbanLineageNode } from './agent-kanban-lineage' + +type AgentKanbanLineageProps = { + nodes: AgentKanbanLineageNode[] +} & AgentKanbanLineageContext + +type AgentKanbanLineageContext = { + cardsByPaneKey: Map + repoIconsByRepoId: Record | undefined + now: number + onOpenTerminal: (card: DashboardCard) => void +} + +function cardHeading(card: DashboardCard): string { + return card.conversationName ?? card.worktreeName +} + +function LineageNode({ + node, + cardsByPaneKey, + repoIconsByRepoId, + now, + onOpenTerminal, + depth +}: AgentKanbanLineageContext & { + node: AgentKanbanLineageNode + depth: number +}): React.JSX.Element { + const parent = node.card.parentPaneKey ? cardsByPaneKey.get(node.card.parentPaneKey) : undefined + const crossColumnParent = depth === 0 && parent?.bucket !== node.card.bucket ? parent : undefined + + return ( +
    + {depth > 0 ? ( + + ) : null} + {crossColumnParent ? ( +
    + + + {translate('dashboardPopout.card.spawnedBy', 'Spawned by {{name}}', { + name: cardHeading(crossColumnParent) + })} + +
    + ) : null} + + {node.children.length ? ( +
    + {node.children.map((child) => ( + + ))} +
    + ) : null} +
    + ) +} + +export function AgentKanbanLineage(props: AgentKanbanLineageProps): React.JSX.Element { + return ( + <> + {props.nodes.map((node) => ( + + ))} + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentFleetRings.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx similarity index 80% rename from src/renderer/src/components/dashboard-popout/AgentFleetRings.test.tsx rename to src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index daad0759a56..01b41bbf8a4 100644 --- a/src/renderer/src/components/dashboard-popout/AgentFleetRings.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -4,7 +4,7 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import { AgentFleetRings } from './AgentFleetRings' +import { AgentMap } from './AgentMap' const NOW = 2_000_000_000 @@ -15,13 +15,13 @@ function card(overrides: Partial = {}): DashboardCard { agentType: 'codex', bucket: 'working', dotState: 'working', - task: 'Build rings', + task: 'Build map', repoId: 'repo-1', worktreeId: 'worktree-1', tabId: 'tab-1', leafId: 'leaf-1', repoName: 'Orca', - worktreeName: 'Fleet rings', + worktreeName: 'Agent map', conversationName: 'Agent alpha', startedAt: NOW - 10 * 60_000, finishedAt: null, @@ -40,6 +40,8 @@ function renderRings( pinnedPaneKeys = new Set(), onMarkReviewed = vi.fn(), onOpenTerminal = vi.fn(), + onSpawnAgent = vi.fn(), + launchableAgentsByWorktreeId = {}, selectedPaneKey = null, compact = false }: { @@ -47,25 +49,31 @@ function renderRings( pinnedPaneKeys?: ReadonlySet onMarkReviewed?: (cards: DashboardCard[]) => void onOpenTerminal?: (card: DashboardCard, side: 'left' | 'right') => void + onSpawnAgent?: React.ComponentProps['onSpawnAgent'] + launchableAgentsByWorktreeId?: React.ComponentProps< + typeof AgentMap + >['launchableAgentsByWorktreeId'] selectedPaneKey?: string | null compact?: boolean } = {} ): ReturnType { return render( - ) } -describe('AgentFleetRings', () => { +describe('AgentMap', () => { const originalUserAgent = navigator.userAgent let boundsSpy: ReturnType @@ -74,7 +82,7 @@ describe('AgentFleetRings', () => { boundsSpy = vi .spyOn(Element.prototype, 'getBoundingClientRect') .mockImplementation(function getBounds(this: Element) { - if (this.classList.contains('fleet-rings-canvas') || this instanceof SVGSVGElement) { + if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { return { x: 0, y: 0, @@ -127,10 +135,33 @@ describe('AgentFleetRings', () => { const doneNode = screen.getByRole('button', { name: /Finished agent/ }) expect(workingNode).toHaveClass('fleet-status-working') expect(doneNode).toHaveClass('fleet-status-done') - expect(workingNode.querySelector('.fleet-agent-icon svg')).toBeInTheDocument() - expect(workingNode.querySelector('[data-agent-spinner]')).toBeInTheDocument() - expect(doneNode.querySelector('.fleet-agent-icon svg')).toBeInTheDocument() - expect(doneNode.querySelector('.fleet-agent-state svg')).toBeInTheDocument() + expect(workingNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + expect(workingNode.querySelector('[data-agent-state-marker]')).toBeInTheDocument() + expect(doneNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + expect(doneNode.querySelector('[data-agent-state-marker]')).toBeInTheDocument() + }) + + it('shows worktree details and starts an agent in that workspace', () => { + const onSpawnAgent = vi.fn() + const running = card() + const { container } = renderRings([running], { + onSpawnAgent, + launchableAgentsByWorktreeId: { 'worktree-1': ['codex'] } + }) + + fireEvent.click(container.querySelector('.agent-map-worktree-label')!) + expect(screen.queryByText('Start a new agent')).not.toBeInTheDocument() + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + fireEvent.click(ring) + + expect(ring).toHaveClass('is-open') + expect(screen.queryByRole('button', { name: 'Focus ring' })).not.toBeInTheDocument() + expect(screen.getAllByText('Orca')).toHaveLength(2) + expect(screen.getByText('1 agent · 1 active · 0 done')).toBeInTheDocument() + expect(screen.getByText('Agent alpha')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Codex/ })) + expect(onSpawnAgent).toHaveBeenCalledWith({ worktreeId: 'worktree-1', agent: 'codex' }) + expect(ring).not.toHaveClass('is-open') }) it('connects orchestrated workers beneath their visible parent', () => { @@ -146,7 +177,7 @@ describe('AgentFleetRings', () => { conversationName: 'Orphaned worker' }) const { container } = renderRings([parent, child, orphan]) - const links = container.querySelectorAll('[data-fleet-agent-lineage-link]') + const links = container.querySelectorAll('[data-agent-map-lineage-link]') expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() @@ -174,7 +205,7 @@ describe('AgentFleetRings', () => { it('increases map label scale when users zoom out', () => { const { container } = renderRings([card()]) - const labelGroup = container.querySelector('.fleet-worktree-label')?.parentElement + const labelGroup = container.querySelector('.agent-map-worktree-label')?.parentElement const initialScale = Number( labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) @@ -198,7 +229,7 @@ describe('AgentFleetRings', () => { vi.stubGlobal('requestAnimationFrame', requestFrame) vi.stubGlobal('cancelAnimationFrame', vi.fn()) const { container } = renderRings([card()]) - const svg = container.querySelector('.fleet-rings-canvas > svg')! + const svg = container.querySelector('.agent-map-canvas > svg')! Object.assign(svg, { setPointerCapture: vi.fn(), releasePointerCapture: vi.fn() @@ -251,8 +282,8 @@ describe('AgentFleetRings', () => { unseen: true }) const { container } = renderRings([card(), quiet]) - const labels = [...container.querySelectorAll('.fleet-worktree-label')] - const activeGroup = labels.find((label) => label.textContent === 'Fleet rings')?.parentElement + const labels = [...container.querySelectorAll('.agent-map-worktree-label')] + const activeGroup = labels.find((label) => label.textContent === 'Agent map')?.parentElement const quietGroup = labels.find((label) => label.textContent === 'Quiet result')?.parentElement expect(activeGroup).toHaveClass('is-visible') @@ -335,10 +366,10 @@ describe('AgentFleetRings', () => { ) const { container } = renderRings(results) - expect(container.querySelectorAll('[data-fleet-agent]')).toHaveLength(5) - expect(container.querySelectorAll('.fleet-aggregate-node')).toHaveLength(0) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0) fireEvent.click(screen.getByRole('button', { name: /^All finished/ })) - expect(container.querySelectorAll('[data-fleet-agent]')).toHaveLength(0) - expect(container.querySelectorAll('.fleet-aggregate-node')).toHaveLength(1) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(1) }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentFleetRings.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx similarity index 68% rename from src/renderer/src/components/dashboard-popout/AgentFleetRings.tsx rename to src/renderer/src/components/dashboard-popout/AgentMap.tsx index c7a2c099982..b42d69cbb4d 100644 --- a/src/renderer/src/components/dashboard-popout/AgentFleetRings.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -2,20 +2,24 @@ import { useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import type { DashboardCard, DashboardCardHostKind } from '../../../../shared/dashboard-snapshot' -import { FleetRingsCanvas, type FleetRingsCanvasHandle } from './FleetRingsCanvas' -import { FleetRingsFocusRail } from './FleetRingsFocusRail' +import type { + DashboardCard, + DashboardCardHostKind, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas' +import { AgentMapFocusRail } from './AgentMapFocusRail' import { - countFleetFocusCards, - filterFleetFocusCards, - type FleetFinishedScope, - type FleetFocusState, - type FleetHostFilter -} from './fleet-rings-focus-filter' -import { updateFleetRingsLayout, type FleetRingsLayoutCache } from './fleet-rings-layout' -import './fleet-rings.css' + countAgentMapCards, + filterAgentMapCards, + type AgentMapFinishedScope, + type AgentMapFocusState, + type AgentMapHostFilter +} from './agent-map-focus-filter' +import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout' +import './agent-map.css' -type AgentFleetRingsProps = { +type AgentMapProps = { cards: DashboardCard[] now: number className?: string @@ -23,29 +27,33 @@ type AgentFleetRingsProps = { selectedPaneKey?: string | null pinnedPaneKeys: ReadonlySet reviewedPaneKeys: ReadonlySet + launchableAgentsByWorktreeId?: Record onMarkReviewed: (cards: DashboardCard[]) => void onOpenTerminal: (card: DashboardCard, side: 'left' | 'right') => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void } -const HOST_FILTERS: FleetHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote'] -const DEFAULT_STATES: FleetFocusState[] = ['attention', 'working', 'finished'] +const HOST_FILTERS: AgentMapHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote'] +const DEFAULT_STATES: AgentMapFocusState[] = ['attention', 'working', 'finished'] +const EMPTY_LAUNCHABLE_AGENTS: Record = {} +const ignoreSpawnAgent = (): void => undefined -function hostFilterLabel(filter: FleetHostFilter): string { +function hostFilterLabel(filter: AgentMapHostFilter): string { switch (filter) { case 'all': - return translate('dashboardPopout.rings.host.all', 'All hosts') + return translate('dashboardPopout.map.host.all', 'All hosts') case 'local': - return translate('dashboardPopout.rings.host.local', 'Local') + return translate('dashboardPopout.map.host.local', 'Local') case 'ssh': - return translate('dashboardPopout.rings.host.ssh', 'SSH') + return translate('dashboardPopout.map.host.ssh', 'SSH') case 'wsl': - return translate('dashboardPopout.rings.host.wsl', 'WSL') + return translate('dashboardPopout.map.host.wsl', 'WSL') case 'remote': - return translate('dashboardPopout.rings.host.remote', 'Remote') + return translate('dashboardPopout.map.host.remote', 'Remote') } } -export function AgentFleetRings({ +export function AgentMap({ cards, now, className, @@ -53,16 +61,18 @@ export function AgentFleetRings({ selectedPaneKey = null, pinnedPaneKeys, reviewedPaneKeys, + launchableAgentsByWorktreeId = EMPTY_LAUNCHABLE_AGENTS, onMarkReviewed, - onOpenTerminal -}: AgentFleetRingsProps): React.JSX.Element { - const canvasRef = useRef(null) - const layoutCacheRef = useRef(null) - const [hostFilter, setHostFilter] = useState('all') - const [enabledStates, setEnabledStates] = useState>( + onOpenTerminal, + onSpawnAgent = ignoreSpawnAgent +}: AgentMapProps): React.JSX.Element { + const canvasRef = useRef(null) + const layoutCacheRef = useRef(null) + const [hostFilter, setHostFilter] = useState('all') + const [enabledStates, setEnabledStates] = useState>( () => new Set(DEFAULT_STATES) ) - const [finishedScope, setFinishedScope] = useState('review') + const [finishedScope, setFinishedScope] = useState('review') const [hiddenProjectIds, setHiddenProjectIds] = useState>(() => new Set()) const hostCounts = useMemo(() => { const counts: Record = { @@ -89,12 +99,12 @@ export function AgentFleetRings({ return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)) }, [cards]) const counts = useMemo( - () => countFleetFocusCards(cards, now, reviewedPaneKeys), + () => countAgentMapCards(cards, now, reviewedPaneKeys), [cards, now, reviewedPaneKeys] ) const visibleCards = useMemo( () => - filterFleetFocusCards({ + filterAgentMapCards({ cards, enabledStates, finishedScope, @@ -127,12 +137,12 @@ export function AgentFleetRings({ [cards, pinnedPaneKeys] ) const layout = useMemo(() => { - const next = updateFleetRingsLayout(layoutCacheRef.current, visibleCards, now) + const next = updateAgentMapLayout(layoutCacheRef.current, visibleCards, now) layoutCacheRef.current = next.cache return next.layout }, [visibleCards, now]) - const toggleState = (state: FleetFocusState): void => { + const toggleState = (state: AgentMapFocusState): void => { setEnabledStates((current) => { const next = new Set(current) if (next.has(state)) { @@ -164,7 +174,7 @@ export function AgentFleetRings({ return (
    {!compact ? ( - - {translate('dashboardPopout.rings.liveContainmentMap', 'Live containment map')} + {translate('dashboardPopout.map.liveContainmentMap', 'Live containment map')} {translate( - 'dashboardPopout.rings.focus.canvasSummary', + 'dashboardPopout.map.focus.canvasSummary', 'Focus · {{shown}} of {{total}} agents', { shown: visibleCards.length, @@ -203,7 +213,7 @@ export function AgentFleetRings({
    {HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map( (option) => ( @@ -226,13 +236,15 @@ export function AgentFleetRings({
    ) : null} -
    diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx new file mode 100644 index 00000000000..5a2e89625a6 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' + +const ringRender = vi.hoisted(() => vi.fn()) +vi.mock('./AgentMapWorktreeRingNode', () => ({ + AgentMapWorktreeRingNode: ({ worktree }: { worktree: { id: string } }) => { + ringRender(worktree.id) + return + } +})) + +import { AgentMap } from './AgentMap' + +const NOW = 2_000_000_000 +const CARD: DashboardCard = { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Measure map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Performance', + startedAt: NOW - 1_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false +} + +describe('AgentMapCanvas pointer performance', () => { + const frames: FrameRequestCallback[] = [] + + beforeEach(() => { + frames.length = 0 + ringRender.mockClear() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 560, + width: 800, + height: 560, + toJSON: () => ({}) + }) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('coalesces drag frames without rerendering worktree nodes', () => { + const { container } = render( + + ) + const svg = container.querySelector('.agent-map-canvas > svg')! + Object.assign(svg, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn() + }) + expect(ringRender).toHaveBeenCalledTimes(1) + + fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 60, clientY: 20 }) + expect(frames).toHaveLength(1) + + act(() => frames.shift()?.(0)) + expect(ringRender).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/FleetRingsCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx similarity index 55% rename from src/renderer/src/components/dashboard-popout/FleetRingsCanvas.tsx rename to src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index 02a71c5818a..9e569318943 100644 --- a/src/renderer/src/components/dashboard-popout/FleetRingsCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -1,16 +1,23 @@ -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' -import { Focus, Minus, Plus } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' import { translate } from '@/i18n/i18n' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' import type { - FleetAgentNode, - FleetProjectRing, - FleetRingsLayout, - FleetWorktreeRing -} from './fleet-rings-layout' -import { shouldAggregateFleetWorktree } from './fleet-rings-layout' -import { FleetRingsMap } from './FleetRingsMap' + AgentMapAgentNode, + AgentMapProjectRing, + AgentMapLayout, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { AgentMapScene } from './AgentMapScene' +import { AgentMapViewportControls } from './AgentMapViewportControls' const MIN_ZOOM = 0.7 const MAX_ZOOM = 4 @@ -19,34 +26,36 @@ type Point = { x: number; y: number } type ViewportSize = { width: number; height: number } type Viewport = { center: Point; zoom: number } -export type FleetRingsCanvasHandle = { +export type AgentMapCanvasHandle = { fit: () => void - focusProject: (project: FleetProjectRing) => void + focusProject: (project: AgentMapProjectRing) => void } -type FleetRingsCanvasProps = { - layout: FleetRingsLayout +type AgentMapCanvasProps = { + layout: AgentMapLayout selectedPaneKey: string | null allowAggregation: boolean + launchableAgentsByWorktreeId: Record onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void + onSpawnAgent: (args: DashboardSpawnAgentArgs) => void } function clamp(value: number, minimum: number, maximum: number): number { return Math.max(minimum, Math.min(maximum, value)) } -function allAgents(layout: FleetRingsLayout): FleetAgentNode[] { +function allAgents(layout: AgentMapLayout): AgentMapAgentNode[] { return layout.projects.flatMap((project) => project.worktrees.flatMap((worktree) => worktree.agents) ) } function nextDirectionalAgent( - current: FleetAgentNode, - agents: FleetAgentNode[], + current: AgentMapAgentNode, + agents: AgentMapAgentNode[], direction: Point -): FleetAgentNode | null { - let best: { agent: FleetAgentNode; score: number } | null = null +): AgentMapAgentNode | null { + let best: { agent: AgentMapAgentNode; score: number } | null = null for (const candidate of agents) { if (candidate.card.paneKey === current.card.paneKey) { continue @@ -66,9 +75,16 @@ function nextDirectionalAgent( return best?.agent ?? null } -export const FleetRingsCanvas = forwardRef( - function FleetRingsCanvas( - { layout, selectedPaneKey, allowAggregation, onSelectAgent }, +export const AgentMapCanvas = forwardRef( + function AgentMapCanvas( + { + layout, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + onSelectAgent, + onSpawnAgent + }, forwardedRef ): React.JSX.Element { const containerRef = useRef(null) @@ -103,13 +119,13 @@ export const FleetRingsCanvas = forwardRef { + const applyViewport = useCallback((next: Viewport): void => { viewportRef.current = next pendingViewportRef.current = null interactionBoundsRef.current = null setViewport(next) - } - const scheduleViewport = (next: Viewport): void => { + }, []) + const scheduleViewport = useCallback((next: Viewport): void => { viewportRef.current = next pendingViewportRef.current = next if (viewportFrameRef.current !== null) { @@ -124,34 +140,26 @@ export const FleetRingsCanvas = forwardRef { + }, []) + const fit = useCallback((): void => { applyViewport({ center: { x: layout.width / 2, y: layout.height / 2 }, zoom: 1 }) - } - const focusProject = (project: FleetProjectRing): void => { - const projectWidth = project.radius * 2.5 - const projectHeight = project.radius * 2.5 - applyViewport({ - center: { x: project.x, y: project.y }, - zoom: clamp( - Math.min(baseWidth / projectWidth, baseHeight / projectHeight), - MIN_ZOOM, - MAX_ZOOM - ) - }) - } - const focusWorktree = (worktree: FleetWorktreeRing): void => { - applyViewport({ - center: { x: worktree.x, y: worktree.y }, - zoom: clamp( - Math.min(baseWidth / (worktree.radius * 2.7), baseHeight / (worktree.radius * 2.7)), - MIN_ZOOM, - MAX_ZOOM - ) - }) - } - - useImperativeHandle(forwardedRef, () => ({ fit, focusProject })) + }, [applyViewport, layout.height, layout.width]) + const focusProject = useCallback( + (project: AgentMapProjectRing): void => { + const projectWidth = project.radius * 2.5 + const projectHeight = project.radius * 2.5 + applyViewport({ + center: { x: project.x, y: project.y }, + zoom: clamp( + Math.min(baseWidth / projectWidth, baseHeight / projectHeight), + MIN_ZOOM, + MAX_ZOOM + ) + }) + }, + [applyViewport, baseHeight, baseWidth] + ) + useImperativeHandle(forwardedRef, () => ({ fit, focusProject }), [fit, focusProject]) useEffect(() => { const container = containerRef.current @@ -201,90 +209,94 @@ export const FleetRingsCanvas = forwardRef, - agent: FleetAgentNode, - worktree: FleetWorktreeRing - ): void => { - if (event.key === 'Enter' || event.key === ' ') { + const handleAgentKeyDown = useCallback( + ( + event: React.KeyboardEvent, + agent: AgentMapAgentNode, + worktree: AgentMapWorktreeRing + ): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + const bounds = event.currentTarget.getBoundingClientRect() + const side = bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left' + onSelectAgent(agent.card, side) + return + } + const direction = + event.key === 'ArrowLeft' + ? { x: -1, y: 0 } + : event.key === 'ArrowRight' + ? { x: 1, y: 0 } + : event.key === 'ArrowUp' + ? { x: 0, y: -1 } + : event.key === 'ArrowDown' + ? { x: 0, y: 1 } + : null + if (!direction) { + return + } event.preventDefault() - const bounds = event.currentTarget.getBoundingClientRect() - const side = bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left' - onSelectAgent(agent.card, side) - return - } - const direction = - event.key === 'ArrowLeft' - ? { x: -1, y: 0 } - : event.key === 'ArrowRight' - ? { x: 1, y: 0 } - : event.key === 'ArrowUp' - ? { x: 0, y: -1 } - : event.key === 'ArrowDown' - ? { x: 0, y: 1 } - : null - if (!direction) { - return - } - event.preventDefault() - const visibleAgents = shouldAggregateFleetWorktree(worktree, zoom, allowAggregation) - ? agents.filter( - (candidate) => - !worktree.agents.some( - (worktreeAgent) => worktreeAgent.card.paneKey === candidate.card.paneKey - ) - ) - : agents - const next = nextDirectionalAgent(agent, visibleAgents, direction) - nodeRefs.current.get(next?.card.paneKey ?? '')?.focus() - } + const visibleAgents = shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + ? agents.filter( + (candidate) => + !worktree.agents.some( + (worktreeAgent) => worktreeAgent.card.paneKey === candidate.card.paneKey + ) + ) + : agents + const next = nextDirectionalAgent(agent, visibleAgents, direction) + nodeRefs.current.get(next?.card.paneKey ?? '')?.focus() + }, + [agents, allowAggregation, onSelectAgent, zoom] + ) - const zoomAt = (nextZoom: number, clientX?: number, clientY?: number): void => { - const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM) - if (clientX === undefined || clientY === undefined) { - applyViewport({ ...viewportRef.current, zoom: clampedZoom }) - return - } - const bounds = interactionBoundsRef.current ?? svgRef.current?.getBoundingClientRect() ?? null - if (!bounds || bounds.width <= 0 || bounds.height <= 0) { - applyViewport({ ...viewportRef.current, zoom: clampedZoom }) - return - } - interactionBoundsRef.current = bounds - const current = viewportRef.current - const currentWidth = baseWidth / current.zoom - const currentHeight = baseHeight / current.zoom - const anchorX = - current.center.x - - currentWidth / 2 + - ((clientX - bounds.left) / bounds.width) * currentWidth - const anchorY = - current.center.y - - currentHeight / 2 + - ((clientY - bounds.top) / bounds.height) * currentHeight - const nextWidth = baseWidth / clampedZoom - const nextHeight = baseHeight / clampedZoom - const xRatio = (clientX - bounds.left) / bounds.width - const yRatio = (clientY - bounds.top) / bounds.height - scheduleViewport({ - center: { - x: anchorX - (xRatio - 0.5) * nextWidth, - y: anchorY - (yRatio - 0.5) * nextHeight - }, - zoom: clampedZoom - }) - } + const zoomAt = useCallback( + (nextZoom: number, clientX?: number, clientY?: number): void => { + const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM) + if (clientX === undefined || clientY === undefined) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + const bounds = + interactionBoundsRef.current ?? svgRef.current?.getBoundingClientRect() ?? null + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + interactionBoundsRef.current = bounds + const current = viewportRef.current + const currentWidth = baseWidth / current.zoom + const currentHeight = baseHeight / current.zoom + const anchorX = + current.center.x - + currentWidth / 2 + + ((clientX - bounds.left) / bounds.width) * currentWidth + const anchorY = + current.center.y - + currentHeight / 2 + + ((clientY - bounds.top) / bounds.height) * currentHeight + const nextWidth = baseWidth / clampedZoom + const nextHeight = baseHeight / clampedZoom + const xRatio = (clientX - bounds.left) / bounds.width + const yRatio = (clientY - bounds.top) / bounds.height + scheduleViewport({ + center: { + x: anchorX - (xRatio - 0.5) * nextWidth, + y: anchorY - (yRatio - 0.5) * nextHeight + }, + zoom: clampedZoom + }) + }, + [applyViewport, baseHeight, baseWidth, scheduleViewport] + ) return ( -
    +
    {layout.projects.length === 0 ? (
    - {translate('dashboardPopout.rings.empty', 'No agents match the current filters.')} + {translate('dashboardPopout.map.empty', 'No agents match the current filters.')}
    ) : ( { @@ -304,7 +316,11 @@ export const FleetRingsCanvas = forwardRef { - if ((event.target as Element).closest('[data-fleet-agent]')) { + if ( + (event.target as Element).closest( + '[data-agent-map-agent], .agent-map-worktree-ring' + ) + ) { return } const bounds = event.currentTarget.getBoundingClientRect() @@ -346,48 +362,28 @@ export const FleetRingsCanvas = forwardRef - )} -
    - - - {Math.round(zoom * 100)}% - - - -
    + zoomAt(viewportRef.current.zoom * 1.25)} + onZoomOut={() => zoomAt(viewportRef.current.zoom / 1.25)} + />
    ) } diff --git a/src/renderer/src/components/dashboard-popout/FleetRingsFocusRail.tsx b/src/renderer/src/components/dashboard-popout/AgentMapFocusRail.tsx similarity index 78% rename from src/renderer/src/components/dashboard-popout/FleetRingsFocusRail.tsx rename to src/renderer/src/components/dashboard-popout/AgentMapFocusRail.tsx index 8f5788a62c2..526ef2ccabc 100644 --- a/src/renderer/src/components/dashboard-popout/FleetRingsFocusRail.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapFocusRail.tsx @@ -5,37 +5,37 @@ import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' import type { - FleetFinishedScope, - FleetFocusCounts, - FleetFocusState -} from './fleet-rings-focus-filter' + AgentMapFinishedScope, + AgentMapFocusCounts, + AgentMapFocusState +} from './agent-map-focus-filter' -type FleetProjectOption = { +type AgentMapProjectOption = { id: string name: string count: number } -type FleetRingsFocusRailProps = { - counts: FleetFocusCounts - enabledStates: ReadonlySet - finishedScope: FleetFinishedScope +type AgentMapFocusRailProps = { + counts: AgentMapFocusCounts + enabledStates: ReadonlySet + finishedScope: AgentMapFinishedScope hiddenProjectIds: ReadonlySet pinnedCount: number - projects: FleetProjectOption[] + projects: AgentMapProjectOption[] reviewableVisibleCards: DashboardCard[] totalCount: number visibleCount: number - onFinishedScopeChange: (scope: FleetFinishedScope) => void + onFinishedScopeChange: (scope: AgentMapFinishedScope) => void onFit: () => void onMarkReviewed: (cards: DashboardCard[]) => void onProjectToggle: (projectId: string) => void onShowAll: () => void - onStateToggle: (state: FleetFocusState) => void + onStateToggle: (state: AgentMapFocusState) => void } const STATE_ROWS: { - state: FleetFocusState + state: AgentMapFocusState dotState: 'waiting' | 'working' | 'done' }[] = [ { state: 'attention', dotState: 'waiting' }, @@ -43,33 +43,33 @@ const STATE_ROWS: { { state: 'finished', dotState: 'done' } ] -const FINISHED_SCOPES: FleetFinishedScope[] = ['review', 'day', 'week', 'all'] +const FINISHED_SCOPES: AgentMapFinishedScope[] = ['review', 'day', 'week', 'all'] -function stateLabel(state: FleetFocusState): string { +function stateLabel(state: AgentMapFocusState): string { switch (state) { case 'attention': return translate('dashboardPopout.bucket.attention', 'Needs You') case 'working': return translate('dashboardPopout.bucket.working', 'Working') case 'finished': - return translate('dashboardPopout.rings.focus.finished', 'Finished') + return translate('dashboardPopout.map.focus.finished', 'Finished') } } -function finishedScopeLabel(scope: FleetFinishedScope): string { +function finishedScopeLabel(scope: AgentMapFinishedScope): string { switch (scope) { case 'review': - return translate('dashboardPopout.rings.focus.toReview', 'To review') + return translate('dashboardPopout.map.focus.toReview', 'To review') case 'day': - return translate('dashboardPopout.rings.focus.lastDay', 'Last 24 hours') + return translate('dashboardPopout.map.focus.lastDay', 'Last 24 hours') case 'week': - return translate('dashboardPopout.rings.focus.lastWeek', 'Last 7 days') + return translate('dashboardPopout.map.focus.lastWeek', 'Last 7 days') case 'all': - return translate('dashboardPopout.rings.focus.allFinished', 'All finished') + return translate('dashboardPopout.map.focus.allFinished', 'All finished') } } -export function FleetRingsFocusRail({ +export function AgentMapFocusRail({ counts, enabledStates, finishedScope, @@ -85,19 +85,19 @@ export function FleetRingsFocusRail({ onProjectToggle, onShowAll, onStateToggle -}: FleetRingsFocusRailProps): React.JSX.Element { +}: AgentMapFocusRailProps): React.JSX.Element { return (
    diff --git a/src/renderer/src/components/dashboard-popout/FleetRingsLegend.tsx b/src/renderer/src/components/dashboard-popout/AgentMapLegend.tsx similarity index 70% rename from src/renderer/src/components/dashboard-popout/FleetRingsLegend.tsx rename to src/renderer/src/components/dashboard-popout/AgentMapLegend.tsx index 62cb5db08f6..5582d497ff4 100644 --- a/src/renderer/src/components/dashboard-popout/FleetRingsLegend.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapLegend.tsx @@ -2,11 +2,11 @@ import { Focus, Layers3 } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import type { DashboardCardDotState } from '../../../../shared/dashboard-snapshot' -import type { FleetProjectRing, FleetRingsLayout } from './fleet-rings-layout' +import type { AgentMapProjectRing, AgentMapLayout } from './agent-map-layout' -type FleetRingsLegendProps = { - layout: FleetRingsLayout - onFocusProject: (project: FleetProjectRing) => void +type AgentMapLegendProps = { + layout: AgentMapLayout + onFocusProject: (project: AgentMapProjectRing) => void onFit: () => void } @@ -15,23 +15,23 @@ const STATUS_ROWS: DashboardCardDotState[] = ['waiting', 'blocked', 'working', ' function stateLabel(state: DashboardCardDotState): string { switch (state) { case 'waiting': - return translate('dashboardPopout.rings.state.waiting', 'Waiting for input') + return translate('dashboardPopout.map.state.waiting', 'Waiting for input') case 'blocked': - return translate('dashboardPopout.rings.state.blocked', 'Blocked') + return translate('dashboardPopout.map.state.blocked', 'Blocked') case 'working': - return translate('dashboardPopout.rings.state.working', 'Working') + return translate('dashboardPopout.map.state.working', 'Working') case 'done': - return translate('dashboardPopout.rings.state.done', 'Done') + return translate('dashboardPopout.map.state.done', 'Done') case 'idle': - return translate('dashboardPopout.rings.state.idle', 'Idle') + return translate('dashboardPopout.map.state.idle', 'Idle') } } -export function FleetRingsLegend({ +export function AgentMapLegend({ layout, onFocusProject, onFit -}: FleetRingsLegendProps): React.JSX.Element { +}: AgentMapLegendProps): React.JSX.Element { const counts = { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } for (const project of layout.projects) { for (const worktree of project.worktrees) { @@ -46,14 +46,14 @@ export function FleetRingsLegend({
    - {translate('dashboardPopout.rings.canvasLayers', 'Canvas layers')} + {translate('dashboardPopout.map.canvasLayers', 'Canvas layers')} - {translate('dashboardPopout.rings.hierarchy', 'Project → workspace → agent')} + {translate('dashboardPopout.map.hierarchy', 'Project → workspace → agent')}

    {translate( - 'dashboardPopout.rings.hierarchyCopy', + 'dashboardPopout.map.hierarchyCopy', 'Every agent keeps a stable place in its ownership tree.' )}

    @@ -74,7 +74,7 @@ export function FleetRingsLegend({ {project.name} - {translate('dashboardPopout.rings.workspaceCount', '{{count}} workspaces', { + {translate('dashboardPopout.map.workspaceCount', '{{count}} workspaces', { count: project.worktrees.length })} @@ -89,7 +89,7 @@ export function FleetRingsLegend({
    - {translate('dashboardPopout.rings.agentState', 'Agent state')} + {translate('dashboardPopout.map.agentState', 'Agent state')}
    {STATUS_ROWS.map((state) => ( @@ -97,7 +97,7 @@ export function FleetRingsLegend({ key={state} className="grid grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-1.5 px-1 text-[11px] text-muted-foreground" > - + {stateLabel(state)} {counts[state]}
    @@ -107,7 +107,7 @@ export function FleetRingsLegend({
    - {translate('dashboardPopout.rings.agentSize', 'Agent size')} + {translate('dashboardPopout.map.agentSize', 'Agent size')}
    {[4, 6.5, 9, 12].map((radius) => ( @@ -119,38 +119,38 @@ export function FleetRingsLegend({ ))}
    - {translate('dashboardPopout.rings.justStarted', 'Just started')} - {translate('dashboardPopout.rings.workingLonger', 'Working longer')} + {translate('dashboardPopout.map.justStarted', 'Just started')} + {translate('dashboardPopout.map.workingLonger', 'Working longer')}

    - {translate('dashboardPopout.rings.sizeScale', 'Logarithmic scale · capped after 60m')} + {translate('dashboardPopout.map.sizeScale', 'Logarithmic scale · capped after 60m')}

    - {translate('dashboardPopout.rings.hover', 'Hover')} + {translate('dashboardPopout.map.hover', 'Hover')} - {translate('dashboardPopout.rings.previewTerminal', 'Preview terminal')} + {translate('dashboardPopout.map.previewTerminal', 'Preview terminal')}

    - {translate('dashboardPopout.rings.click', 'Click')} + {translate('dashboardPopout.map.click', 'Click')} - {translate('dashboardPopout.rings.pinAgent', 'Pin agent')} + {translate('dashboardPopout.map.pinAgent', 'Pin agent')}

    - {translate('dashboardPopout.rings.doubleClick', 'Double-click')} + {translate('dashboardPopout.map.doubleClick', 'Double-click')} - {translate('dashboardPopout.rings.zoomWorkspace', 'Zoom workspace')} + {translate('dashboardPopout.map.zoomWorkspace', 'Zoom workspace')}

    ) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx new file mode 100644 index 00000000000..3ec63a6d4aa --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -0,0 +1,84 @@ +import { memo, type MutableRefObject } from 'react' +import { translate } from '@/i18n/i18n' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { AgentMapAgentNode, AgentMapLayout, AgentMapWorktreeRing } from './agent-map-layout' +import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' + +type AgentMapSceneProps = { + layout: AgentMapLayout + zoom: number + labelScale: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgentsByWorktreeId: Record + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void + onSpawnAgent: (args: DashboardSpawnAgentArgs) => void + onAgentKeyDown: ( + event: React.KeyboardEvent, + agent: AgentMapAgentNode, + worktree: AgentMapWorktreeRing + ) => void +} + +/** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */ +export const AgentMapScene = memo(function AgentMapScene({ + layout, + zoom, + labelScale, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onAgentKeyDown +}: AgentMapSceneProps): React.JSX.Element { + return ( + <> + {layout.projects.map((project) => ( + + + + + {project.name.toUpperCase()} + + + {translate( + 'dashboardPopout.map.projectCount', + '{{agents}} agents · {{workspaces}} workspaces', + { agents: project.agentCount, workspaces: project.worktrees.length } + ).toUpperCase()} + + + {project.worktrees.map((worktree) => ( + + ))} + + ))} + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx new file mode 100644 index 00000000000..6e035dca692 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx @@ -0,0 +1,47 @@ +import { Focus, Minus, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +type AgentMapViewportControlsProps = { + zoom: number + onFit: () => void + onZoomIn: () => void + onZoomOut: () => void +} + +export function AgentMapViewportControls({ + zoom, + onFit, + onZoomIn, + onZoomOut +}: AgentMapViewportControlsProps): React.JSX.Element { + return ( +
    + + + {Math.round(zoom * 100)}% + + + +
    + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx new file mode 100644 index 00000000000..919d93bd1a9 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -0,0 +1,311 @@ +import { memo, useState, type MutableRefObject } from 'react' +import { Plus } from 'lucide-react' +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { agentTypeToIconAgent } from '@/lib/agent-status' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentMapAgentNode, + AgentMapProjectRing, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' + +type AgentMapWorktreeRingNodeProps = { + project: AgentMapProjectRing + worktree: AgentMapWorktreeRing + zoom: number + labelScale: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgents: readonly TuiAgent[] + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard, side: 'left' | 'right') => void + onSpawnAgent: (args: DashboardSpawnAgentArgs) => void + onAgentKeyDown: ( + event: React.KeyboardEvent, + agent: AgentMapAgentNode, + worktree: AgentMapWorktreeRing + ) => void +} + +function formatDuration(minutes: number): string { + if (minutes < 1) { + return translate('dashboardPopout.card.time.justNow', 'just now') + } + if (minutes < 60) { + return translate('dashboardPopout.card.time.minutes', '{{count}}m', { + count: Math.floor(minutes) + }) + } + return translate('dashboardPopout.card.time.hours', '{{count}}h', { + count: Math.floor(minutes / 60) + }) +} + +function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { + const startY = parent.y + parent.radius + const endY = child.y - child.radius + const branchY = (startY + endY) / 2 + return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}` +} + +function panelSideFor(element: Element): 'left' | 'right' { + const bounds = element.getBoundingClientRect() + return bounds.left + bounds.width / 2 <= window.innerWidth / 2 ? 'right' : 'left' +} + +function agentName(card: DashboardCard): string { + return card.conversationName ?? (card.task.trim() || card.agentType) +} + +function WorktreeDetails({ + project, + worktree, + launchableAgents, + onSelectAgent, + onSpawnAgent, + onDone +}: Pick< + AgentMapWorktreeRingNodeProps, + 'project' | 'worktree' | 'launchableAgents' | 'onSelectAgent' | 'onSpawnAgent' +> & { onDone: () => void }): React.JSX.Element { + const activeCount = + worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting + return ( + +
    + {project.name} + {worktree.name} + + {translate( + worktree.agents.length === 1 + ? 'dashboardPopout.map.worktreeSummary_one' + : 'dashboardPopout.map.worktreeSummary_other', + worktree.agents.length === 1 + ? '{{total}} agent · {{active}} active · {{done}} done' + : '{{total}} agents · {{active}} active · {{done}} done', + { + total: worktree.agents.length, + active: activeCount, + done: worktree.statusCounts.done + } + )} + +
    +
    +

    + {translate('dashboardPopout.map.runningAgents', 'Agents')} +

    +
    + {worktree.agents.map((agent) => ( + + ))} +
    +
    +
    +

    + {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} +

    + {launchableAgents.length > 0 ? ( +
    + {launchableAgents.map((agent) => ( + + ))} +
    + ) : ( +

    + {translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')} +

    + )} +
    +
    + ) +} + +export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ + project, + worktree, + zoom, + labelScale, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgents, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onAgentKeyDown +}: AgentMapWorktreeRingNodeProps): React.JSX.Element { + const [detailsOpen, setDetailsOpen] = useState(false) + const aggregate = shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) + const agentsByPaneKey = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + const screenRadius = worktree.radius * mapScale + const showLabel = !worktree.quiet || screenRadius >= 56 + const showCount = screenRadius >= 80 + + return ( + + + + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setDetailsOpen(true) + } + }} + /> + + + + {worktree.name} + + + {translate('dashboardPopout.map.agentCount', '{{count}} agents', { + count: worktree.agents.length + })} + + + {aggregate ? ( + + + {worktree.agents.length} + + ) : ( + <> + + {worktree.agents.map((child) => { + const parent = child.card.parentPaneKey + ? agentsByPaneKey.get(child.card.parentPaneKey) + : undefined + return !parent || child.y <= parent.y ? null : ( + + ) + })} + + {worktree.agents.map((agent) => { + const iconSize = Math.max(12, Math.min(22, agent.radius * 1.05)) + return ( + { + if (node) { + nodeRefs.current.set(agent.card.paneKey, node) + } else { + nodeRefs.current.delete(agent.card.paneKey) + } + }} + data-agent-map-agent="" + data-agent-provider={agent.card.agentType} + role="button" + tabIndex={0} + aria-label={`${agentName(agent.card)}, ${agentStateLabel(agent.status)}, ${formatDuration(agent.durationMinutes)}, ${worktree.name}, ${project.name}`} + className={`agent-map-agent-node fleet-status-${agent.status}${selectedPaneKey === agent.card.paneKey ? ' is-selected' : ''}`} + transform={`translate(${agent.x} ${agent.y})`} + onClick={(event) => onSelectAgent(agent.card, panelSideFor(event.currentTarget))} + onKeyDown={(event) => onAgentKeyDown(event, agent, worktree)} + > + + + +
    + +
    +
    + +
    + ) + })} + + )} +
    + setDetailsOpen(false)} + /> +
    + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentNativeChatDrawer.tsx b/src/renderer/src/components/dashboard-popout/AgentNativeChatDrawer.tsx new file mode 100644 index 00000000000..7a596f9eb99 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentNativeChatDrawer.tsx @@ -0,0 +1,39 @@ +import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet' +import { translate } from '@/i18n/i18n' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { AgentChatPanel } from './AgentChatPanel' + +type AgentNativeChatDrawerProps = { + card: DashboardCard + onClose: () => void + onOpenTerminal: () => void +} + +/** Dashboard-native chat lives in a window-edge drawer, independent of map layout. */ +export function AgentNativeChatDrawer({ + card, + onClose, + onOpenTerminal +}: AgentNativeChatDrawerProps): React.JSX.Element { + return ( + !open && onClose()}> + + + {translate('dashboardPopout.chat.drawerTitle', 'Native chat')} + + + + + ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index 7ced4ca791a..27ee4f52602 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -98,9 +98,9 @@ function AgentTerminalFrame({ <> {reviewed - ? translate('dashboardPopout.rings.focus.reviewed', 'Reviewed result') + ? translate('dashboardPopout.map.focus.reviewed', 'Reviewed result') : translate( - 'dashboardPopout.rings.focus.seenPendingReview', + 'dashboardPopout.map.focus.seenPendingReview', 'Seen · remains in Focus until reviewed' )} @@ -113,8 +113,8 @@ function AgentTerminalFrame({ > {pinned - ? translate('dashboardPopout.rings.focus.pinned', 'Pinned') - : translate('dashboardPopout.rings.focus.keepVisible', 'Keep visible')} + ? translate('dashboardPopout.map.focus.pinned', 'Pinned') + : translate('dashboardPopout.map.focus.keepVisible', 'Keep visible')} {!reviewed ? ( ) : null} diff --git a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx index d16d8d2d0b1..27624adfbce 100644 --- a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx +++ b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx @@ -1,5 +1,7 @@ -import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard' +import { AgentKanbanBoard } from './AgentKanbanBoard' +import { resolveAgentDashboardView } from './agent-dashboard-view' import { useDashboardSnapshot } from './useDashboardSnapshot' +import { AgentDashboardSettingsMenu } from '../dashboard/AgentDashboardSettingsMenu' type DashboardPopoutRootProps = { /** The layout requested via popout.html?view=. */ @@ -12,7 +14,12 @@ type DashboardPopoutRootProps = { */ export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element { const snapshot = useDashboardSnapshot() - const initialView: AgentDashboardView = - _props.view === 'rings' || _props.view === 'cells' ? _props.view : 'board' - return + const initialView = resolveAgentDashboardView(_props.view) ?? 'board' + return ( + } + /> + ) } diff --git a/src/renderer/src/components/dashboard-popout/FleetAgentInspector.tsx b/src/renderer/src/components/dashboard-popout/FleetAgentInspector.tsx index 3c38105e8be..ffc1e66bb34 100644 --- a/src/renderer/src/components/dashboard-popout/FleetAgentInspector.tsx +++ b/src/renderer/src/components/dashboard-popout/FleetAgentInspector.tsx @@ -10,11 +10,11 @@ import { NATIVE_CHAT_SUBMIT } from '@/components/native-chat/native-chat-send' import type { DashboardCard } from '../../../../shared/dashboard-snapshot' -import type { FleetWorktreeRing } from './fleet-rings-layout' +import type { AgentMapWorktreeRing } from './agent-map-layout' type FleetAgentInspectorProps = { card: DashboardCard | null - worktree: FleetWorktreeRing | null + worktree: AgentMapWorktreeRing | null draft: string onDraftChange: (value: string) => void onOpenTerminal: (card: DashboardCard) => void @@ -22,16 +22,16 @@ type FleetAgentInspectorProps = { function compactDuration(card: DashboardCard, now: number): string { if (card.startedAt <= 0) { - return translate('dashboardPopout.rings.durationUnknown', 'Duration unavailable') + return translate('dashboardPopout.map.durationUnknown', 'Duration unavailable') } const end = card.finishedAt ?? now const minutes = Math.max(0, Math.floor((end - card.startedAt) / 60_000)) if (minutes < 60) { - return translate('dashboardPopout.rings.durationMinutes', '{{count}}m active', { + return translate('dashboardPopout.map.durationMinutes', '{{count}}m active', { count: minutes }) } - return translate('dashboardPopout.rings.durationHours', '{{count}}h active', { + return translate('dashboardPopout.map.durationHours', '{{count}}h active', { count: Math.floor(minutes / 60) }) } @@ -51,11 +51,11 @@ export function FleetAgentInspector({ return (
    @@ -142,7 +142,7 @@ export function FleetAgentInspector({ onClick={() => onOpenTerminal(card)} > - {translate('dashboardPopout.rings.openLiveTerminal', 'Open live terminal')} + {translate('dashboardPopout.map.openLiveTerminal', 'Open live terminal')} @@ -152,7 +152,7 @@ export function FleetAgentInspector({ htmlFor={`fleet-response-${card.paneKey}`} className="block border-b border-border px-2.5 py-2 text-[11px] font-semibold" > - {translate('dashboardPopout.rings.respondToAgent', 'Respond to agent')} + {translate('dashboardPopout.map.respondToAgent', 'Respond to agent')}