|
|
|
@ -1,11 +1,13 @@
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* Self-contained AgentHub task board, served at `GET /board`.
|
|
|
|
* Self-contained Trello-like task board, served at `GET /board`.
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* Constraints:
|
|
|
|
* Design constraints (MVP — maximum simplicity):
|
|
|
|
* - One static HTML page: inline CSS + JS, no framework and no runtime deps.
|
|
|
|
* - One static HTML page: inline CSS + JS, no build step, no framework, no npm deps.
|
|
|
|
* - Reads only same-origin endpoints (`/tasks`, `/handoffs`, `/decisions`,
|
|
|
|
* - Reads only the existing same-origin endpoints (`/tasks`, `/handoffs`,
|
|
|
|
* `/tasks/:id/activity`, `/events` and `/status`).
|
|
|
|
* `/decisions`, `/tasks/:id/activity`). It never mutates state.
|
|
|
|
* - Never mutates state.
|
|
|
|
* - Auto-refreshes on a small interval via `setInterval` + `fetch`.
|
|
|
|
|
|
|
|
* - Each task card is clickable: expanding it fetches and renders the
|
|
|
|
|
|
|
|
* per-task activity timeline (created → handoff → result → status).
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export interface BoardColumn {
|
|
|
|
export interface BoardColumn {
|
|
|
|
@ -34,17 +36,7 @@ function columnSkeleton(): string {
|
|
|
|
).join('\n');
|
|
|
|
).join('\n');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(value: string): string {
|
|
|
|
export function renderBoardHtml(): string {
|
|
|
|
return value
|
|
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|
|
|
|
|
|
|
const initialProjectName = JSON.stringify(projectName).replace(/</g, '\\u003c');
|
|
|
|
|
|
|
|
const projectNameHtml = escapeHtml(projectName);
|
|
|
|
|
|
|
|
return `<!doctype html>
|
|
|
|
return `<!doctype html>
|
|
|
|
<html lang="en">
|
|
|
|
<html lang="en">
|
|
|
|
<head>
|
|
|
|
<head>
|
|
|
|
@ -54,132 +46,37 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|
|
|
<title>AgentHub Board</title>
|
|
|
|
<title>AgentHub Board</title>
|
|
|
|
<style>
|
|
|
|
<style>
|
|
|
|
:root {
|
|
|
|
:root {
|
|
|
|
--bg: #0F172A;
|
|
|
|
--bg: #0d1117;
|
|
|
|
--surface: #161B22;
|
|
|
|
--panel: #161b22;
|
|
|
|
--raised: #1E293B;
|
|
|
|
--panel-2: #1c2330;
|
|
|
|
--border: #30363D;
|
|
|
|
--border: #30363d;
|
|
|
|
--text: #F8FAFC;
|
|
|
|
--text: #e6edf3;
|
|
|
|
--muted: #94A3B8;
|
|
|
|
--muted: #8b949e;
|
|
|
|
--accent: #58A6FF;
|
|
|
|
--accent: #58a6ff;
|
|
|
|
--green: #22C55E;
|
|
|
|
--open: #8b949e;
|
|
|
|
--open: #8B949E;
|
|
|
|
--in_progress: #58a6ff;
|
|
|
|
--in_progress: #58A6FF;
|
|
|
|
--review: #d29922;
|
|
|
|
--review: #D29922;
|
|
|
|
--done: #3fb950;
|
|
|
|
--done: #22C55E;
|
|
|
|
--cancelled: #6e7681;
|
|
|
|
--cancelled: #6E7681;
|
|
|
|
|
|
|
|
--danger: #F85149;
|
|
|
|
|
|
|
|
--mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
|
|
|
|
|
|
--sans: system-ui, "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html, body { margin: 0; min-height: 100%; }
|
|
|
|
html, body { margin: 0; height: 100%; }
|
|
|
|
body {
|
|
|
|
body {
|
|
|
|
background: var(--bg);
|
|
|
|
background: var(--bg);
|
|
|
|
color: var(--text);
|
|
|
|
color: var(--text);
|
|
|
|
font: 14px/1.5 var(--sans);
|
|
|
|
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
|
|
padding: 0 20px 32px;
|
|
|
|
padding: 16px 20px 32px;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
button, a, .card { cursor: pointer; }
|
|
|
|
.topbar {
|
|
|
|
a { color: inherit; text-decoration: none; }
|
|
|
|
display: flex; align-items: baseline; gap: 12px;
|
|
|
|
a:focus-visible, .card:focus-visible {
|
|
|
|
margin-bottom: 16px; flex-wrap: wrap;
|
|
|
|
outline: 2px solid var(--accent);
|
|
|
|
|
|
|
|
outline-offset: 3px;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.topbar h1 { font-size: 18px; margin: 0; font-weight: 600; }
|
|
|
|
.app-header {
|
|
|
|
.topbar .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--done); display: inline-block; }
|
|
|
|
position: sticky;
|
|
|
|
.topbar .dot.stale { background: var(--review); }
|
|
|
|
top: 0;
|
|
|
|
.topbar .dot.down { background: #f85149; }
|
|
|
|
z-index: 20;
|
|
|
|
.topbar .meta { color: var(--muted); font-size: 12px; }
|
|
|
|
display: flex;
|
|
|
|
.topbar .spacer { flex: 1; }
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
|
|
min-height: 64px;
|
|
|
|
|
|
|
|
margin: 0 -20px 18px;
|
|
|
|
|
|
|
|
padding: 10px 20px;
|
|
|
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
|
|
|
background: rgba(15, 23, 42, .96);
|
|
|
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.brand {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.mark {
|
|
|
|
|
|
|
|
width: 34px;
|
|
|
|
|
|
|
|
height: 34px;
|
|
|
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
|
|
|
border: 1px solid rgba(88, 166, 255, .38);
|
|
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
|
|
background: var(--raised);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.mark svg { width: 22px; height: 22px; }
|
|
|
|
|
|
|
|
.brand-copy {
|
|
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.brand-title {
|
|
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
|
|
letter-spacing: 0;
|
|
|
|
|
|
|
|
font-size: 17px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.project-name {
|
|
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
font-family: var(--mono);
|
|
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
|
|
max-width: 42vw;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.header-spacer { flex: 1; }
|
|
|
|
|
|
|
|
.nav {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
|
|
padding: 3px;
|
|
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.nav-link {
|
|
|
|
|
|
|
|
min-height: 34px;
|
|
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
padding: 0 12px;
|
|
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
transition: color 180ms ease, background 180ms ease;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.nav-link:hover, .nav-link.active {
|
|
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
|
|
background: var(--raised);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.sse-status {
|
|
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 7px;
|
|
|
|
|
|
|
|
min-height: 34px;
|
|
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
|
|
font: 12px/1 var(--mono);
|
|
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.conn-dot {
|
|
|
|
|
|
|
|
width: 9px;
|
|
|
|
|
|
|
|
height: 9px;
|
|
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
background: var(--green);
|
|
|
|
|
|
|
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, .16);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.sse-status.stale .conn-dot { background: var(--review); box-shadow: 0 0 0 3px rgba(210, 153, 34, .14); }
|
|
|
|
|
|
|
|
.sse-status.down .conn-dot { background: var(--danger); box-shadow: 0 0 0 3px rgba(248, 81, 73, .14); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.board {
|
|
|
|
.board {
|
|
|
|
display: grid;
|
|
|
|
display: grid;
|
|
|
|
@ -187,34 +84,24 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|
|
|
gap: 12px;
|
|
|
|
gap: 12px;
|
|
|
|
align-items: start;
|
|
|
|
align-items: start;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (max-width: 1100px) { .board { grid-template-columns: repeat(2, 1fr); } }
|
|
|
|
|
|
|
|
@media (max-width: 640px) { .board { grid-template-columns: 1fr; } }
|
|
|
|
|
|
|
|
|
|
|
|
.column {
|
|
|
|
.column {
|
|
|
|
background: var(--surface);
|
|
|
|
background: var(--panel);
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
border-radius: 8px;
|
|
|
|
border-radius: 10px;
|
|
|
|
padding: 10px;
|
|
|
|
padding: 10px;
|
|
|
|
min-height: 88px;
|
|
|
|
min-height: 80px;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.col-head {
|
|
|
|
.col-head {
|
|
|
|
display: flex;
|
|
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
|
|
align-items: center;
|
|
|
|
margin-bottom: 10px; padding: 0 2px;
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
|
|
padding: 0 2px 10px;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.col-label {
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
|
|
letter-spacing: 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.col-label { font-weight: 600; font-size: 13px; letter-spacing: .02em; }
|
|
|
|
.col-count {
|
|
|
|
.col-count {
|
|
|
|
min-width: 26px;
|
|
|
|
background: var(--panel-2); color: var(--muted);
|
|
|
|
text-align: center;
|
|
|
|
border-radius: 999px; padding: 1px 8px; font-size: 12px;
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
|
|
padding: 1px 8px;
|
|
|
|
|
|
|
|
background: var(--raised);
|
|
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
|
|
font: 12px/1.45 var(--mono);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.column[data-column="open"] .col-label { color: var(--open); }
|
|
|
|
.column[data-column="open"] .col-label { color: var(--open); }
|
|
|
|
.column[data-column="in_progress"] .col-label { color: var(--in_progress); }
|
|
|
|
.column[data-column="in_progress"] .col-label { color: var(--in_progress); }
|
|
|
|
@ -224,215 +111,90 @@ export function renderBoardHtml(projectName = 'AgentHub Project'): string {
|
|
|
|
|
|
|
|
|
|
|
|
.cards { display: flex; flex-direction: column; gap: 8px; }
|
|
|
|
.cards { display: flex; flex-direction: column; gap: 8px; }
|
|
|
|
.card {
|
|
|
|
.card {
|
|
|
|
background: var(--raised);
|
|
|
|
background: var(--panel-2);
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
border-left: 3px solid var(--accent);
|
|
|
|
border-left: 3px solid var(--accent);
|
|
|
|
border-radius: 8px;
|
|
|
|
border-radius: 8px;
|
|
|
|
padding: 9px 10px;
|
|
|
|
padding: 8px 10px;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
user-select: none;
|
|
|
|
user-select: none;
|
|
|
|
transition: border-color 180ms ease, background 180ms ease;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.card:hover { border-color: rgba(88, 166, 255, .55); background: #223047; }
|
|
|
|
.card:hover { border-color: rgba(139,148,158,.5); }
|
|
|
|
.column[data-column="open"] .card { border-left-color: var(--open); }
|
|
|
|
.column[data-column="open"] .card { border-left-color: var(--open); }
|
|
|
|
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
|
|
|
|
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
|
|
|
|
.column[data-column="review"] .card { border-left-color: var(--review); }
|
|
|
|
.column[data-column="review"] .card { border-left-color: var(--review); }
|
|
|
|
.column[data-column="done"] .card { border-left-color: var(--done); }
|
|
|
|
.column[data-column="done"] .card { border-left-color: var(--done); }
|
|
|
|
.column[data-column="cancelled"] .card { border-left-color: var(--cancelled); }
|
|
|
|
.column[data-column="cancelled"] .card { border-left-color: var(--cancelled); }
|
|
|
|
.card-top {
|
|
|
|
.card .id { color: var(--muted); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
|
display: flex;
|
|
|
|
.card .title { margin: 2px 0 6px; font-weight: 500; }
|
|
|
|
align-items: center;
|
|
|
|
.card .tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
|
|
justify-content: space-between;
|
|
|
|
.badge {
|
|
|
|
gap: 8px;
|
|
|
|
font-size: 11px; border-radius: 999px; padding: 1px 8px;
|
|
|
|
margin-bottom: 5px;
|
|
|
|
background: rgba(88,166,255,.12); color: var(--accent);
|
|
|
|
}
|
|
|
|
border: 1px solid rgba(88,166,255,.25);
|
|
|
|
.id {
|
|
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
|
|
font: 11px/1.3 var(--mono);
|
|
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.title {
|
|
|
|
|
|
|
|
margin: 0 0 8px;
|
|
|
|
|
|
|
|
font-weight: 650;
|
|
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
|
|
line-height: 1.35;
|
|
|
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.meta-row {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.pill, .badge {
|
|
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 5px;
|
|
|
|
|
|
|
|
min-height: 22px;
|
|
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
|
|
padding: 2px 8px;
|
|
|
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, .25);
|
|
|
|
|
|
|
|
background: rgba(148, 163, 184, .08);
|
|
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
|
|
font: 11px/1.2 var(--mono);
|
|
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.status-pill { color: var(--text); }
|
|
|
|
|
|
|
|
.status-open { border-color: rgba(139, 148, 158, .38); color: var(--open); }
|
|
|
|
|
|
|
|
.status-in_progress { border-color: rgba(88, 166, 255, .42); color: var(--in_progress); background: rgba(88, 166, 255, .10); }
|
|
|
|
|
|
|
|
.status-review { border-color: rgba(210, 153, 34, .42); color: var(--review); background: rgba(210, 153, 34, .10); }
|
|
|
|
|
|
|
|
.status-done { border-color: rgba(34, 197, 94, .40); color: var(--done); background: rgba(34, 197, 94, .10); }
|
|
|
|
|
|
|
|
.status-cancelled { border-color: rgba(110, 118, 129, .45); color: var(--cancelled); }
|
|
|
|
|
|
|
|
.project-tag { color: var(--accent); border-color: rgba(88, 166, 255, .30); background: rgba(88, 166, 255, .08); }
|
|
|
|
|
|
|
|
.timer-badge { color: var(--text); background: rgba(15, 23, 42, .45); }
|
|
|
|
|
|
|
|
.avatar {
|
|
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
|
|
min-height: 24px;
|
|
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
|
|
padding: 2px 8px 2px 3px;
|
|
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
|
|
border: 1px solid rgba(148, 163, 184, .25);
|
|
|
|
|
|
|
|
box-shadow: inset 3px 0 0 var(--agent-color);
|
|
|
|
|
|
|
|
background: rgba(15, 23, 42, .45);
|
|
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
|
|
font: 11px/1.2 var(--mono);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.avatar-core {
|
|
|
|
|
|
|
|
width: 18px;
|
|
|
|
|
|
|
|
height: 18px;
|
|
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
|
|
display: inline-grid;
|
|
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
|
|
background: var(--agent-color);
|
|
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
|
|
font-size: 9px;
|
|
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
|
|
letter-spacing: 0;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.avatar.architect { box-shadow: 0 0 0 2px rgba(217, 119, 87, .24); }
|
|
|
|
|
|
|
|
.avatar-label {
|
|
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
|
|
max-width: 120px;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
.badge.role { background: rgba(210,153,34,.12); color: var(--review); border-color: rgba(210,153,34,.25); }
|
|
|
|
|
|
|
|
.badge.agent { background: rgba(63,185,80,.12); color: var(--done); border-color: rgba(63,185,80,.25); }
|
|
|
|
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; }
|
|
|
|
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── Activity timeline ─────────────────────────────────────────────────── */
|
|
|
|
.timeline {
|
|
|
|
.timeline {
|
|
|
|
display: none;
|
|
|
|
display: none;
|
|
|
|
margin-top: 9px;
|
|
|
|
margin-top: 8px;
|
|
|
|
padding-top: 8px;
|
|
|
|
padding-top: 8px;
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.card.expanded .timeline { display: block; }
|
|
|
|
.card.expanded .timeline { display: block; }
|
|
|
|
.tl-row {
|
|
|
|
.tl-row {
|
|
|
|
display: flex;
|
|
|
|
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
|
|
|
|
align-items: baseline;
|
|
|
|
padding: 4px 0; border-top: 1px solid rgba(48,54,61,.5); font-size: 12px;
|
|
|
|
gap: 6px;
|
|
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
|
|
padding: 4px 0;
|
|
|
|
|
|
|
|
border-top: 1px solid rgba(48, 54, 61, .5);
|
|
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.tl-row:first-child { border-top: 0; }
|
|
|
|
.tl-row:first-child { border-top: 0; }
|
|
|
|
.tl-when { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; min-width: 52px; }
|
|
|
|
.tl-when { color: var(--muted); font-size: 11px; white-space: nowrap; min-width: 52px; }
|
|
|
|
.tl-kind {
|
|
|
|
.tl-kind {
|
|
|
|
font: 10px/1.3 var(--mono);
|
|
|
|
font-size: 10px; border-radius: 999px; padding: 1px 6px;
|
|
|
|
border-radius: 999px;
|
|
|
|
border: 1px solid transparent; white-space: nowrap;
|
|
|
|
padding: 1px 6px;
|
|
|
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.tl-kind-created { background: rgba(48, 54, 61, .6); color: var(--muted); border-color: var(--border); }
|
|
|
|
.tl-kind-created { background: rgba(48,54,61,.6); color: var(--muted); border-color: var(--border); }
|
|
|
|
.tl-kind-handoff { background: rgba(88, 166, 255, .12); color: var(--accent); border-color: rgba(88, 166, 255, .25); }
|
|
|
|
.tl-kind-handoff { background: rgba(88,166,255,.12); color: var(--accent); border-color: rgba(88,166,255,.25); }
|
|
|
|
.tl-kind-result { background: rgba(34, 197, 94, .12); color: var(--done); border-color: rgba(34, 197, 94, .25); }
|
|
|
|
.tl-kind-result { background: rgba(63,185,80,.12); color: var(--done); border-color: rgba(63,185,80,.25); }
|
|
|
|
.tl-kind-status { background: rgba(210, 153, 34, .12); color: var(--review); border-color: rgba(210, 153, 34, .25); }
|
|
|
|
.tl-kind-status { background: rgba(210,153,34,.12); color: var(--review); border-color: rgba(210,153,34,.25); }
|
|
|
|
.tl-actor { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; }
|
|
|
|
.tl-actor { color: var(--muted); font-size: 11px; white-space: nowrap; }
|
|
|
|
.tl-summary { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
|
|
.tl-summary { flex: 1; min-width: 0; word-break: break-word; }
|
|
|
|
.tl-meta { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
|
|
.tl-meta { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
|
|
.badge.tl-tokens { color: #A371F7; border-color: rgba(163, 113, 247, .25); background: rgba(163, 113, 247, .12); }
|
|
|
|
.badge.tl-tokens { background: rgba(163,113,247,.12); color: #a371f7; border-color: rgba(163,113,247,.25); }
|
|
|
|
.badge.tl-dur { color: #FFA657; border-color: rgba(255, 166, 87, .25); background: rgba(255, 166, 87, .12); }
|
|
|
|
.badge.tl-dur { background: rgba(255,166,87,.12); color: #ffa657; border-color: rgba(255,166,87,.25); }
|
|
|
|
.tl-loading, .tl-empty { color: var(--muted); font-size: 12px; padding: 2px 0; }
|
|
|
|
.tl-loading, .tl-empty { color: var(--muted); font-size: 12px; padding: 2px 0; }
|
|
|
|
|
|
|
|
|
|
|
|
.panels {
|
|
|
|
.panels {
|
|
|
|
display: grid;
|
|
|
|
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
margin-top: 20px;
|
|
|
|
margin-top: 20px;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (max-width: 640px) { .panels { grid-template-columns: 1fr; } }
|
|
|
|
.panel {
|
|
|
|
.panel {
|
|
|
|
background: var(--surface);
|
|
|
|
background: var(--panel); border: 1px solid var(--border);
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
border-radius: 10px; padding: 12px 14px;
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.panel h2 { font-size: 13px; margin: 0 0 10px; font-weight: 700; }
|
|
|
|
.panel h2 { font-size: 13px; margin: 0 0 10px; font-weight: 600; }
|
|
|
|
.row {
|
|
|
|
.row {
|
|
|
|
display: flex;
|
|
|
|
display: flex; gap: 8px; align-items: baseline;
|
|
|
|
gap: 8px;
|
|
|
|
padding: 6px 0; border-top: 1px solid var(--border);
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
padding: 7px 0;
|
|
|
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
.row:first-of-type { border-top: 0; }
|
|
|
|
.row:first-of-type { border-top: 0; }
|
|
|
|
.row .what { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
|
|
.row .id { color: var(--muted); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }
|
|
|
|
.row .who-cell { display: flex; align-items: center; gap: 4px; font-size: 12px; white-space: nowrap; }
|
|
|
|
.row .what { flex: 1; }
|
|
|
|
.row .who { display: flex; align-items: center; gap: 4px; color: var(--accent); font-size: 11px; white-space: nowrap; }
|
|
|
|
.row .who-cell { font-size: 12px; white-space: nowrap; display: flex; align-items: baseline; gap: 4px; }
|
|
|
|
.row .when { color: var(--muted); font: 11px/1.4 var(--mono); white-space: nowrap; }
|
|
|
|
.row .who { display: flex; align-items: baseline; gap: 4px; color: var(--accent); font-size: 11px; white-space: nowrap; }
|
|
|
|
|
|
|
|
.row .when { color: var(--muted); font-size: 11px; white-space: nowrap; }
|
|
|
|
@media (max-width: 1180px) { .board { grid-template-columns: repeat(3, 1fr); } }
|
|
|
|
|
|
|
|
@media (max-width: 860px) {
|
|
|
|
|
|
|
|
body { padding-left: 14px; padding-right: 14px; }
|
|
|
|
|
|
|
|
.app-header { margin-left: -14px; margin-right: -14px; padding-left: 14px; padding-right: 14px; }
|
|
|
|
|
|
|
|
.board { grid-template-columns: repeat(2, 1fr); }
|
|
|
|
|
|
|
|
.panels { grid-template-columns: 1fr; }
|
|
|
|
|
|
|
|
.project-name { max-width: 50vw; }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (max-width: 560px) {
|
|
|
|
|
|
|
|
.app-header { align-items: flex-start; flex-wrap: wrap; }
|
|
|
|
|
|
|
|
.header-spacer { display: none; }
|
|
|
|
|
|
|
|
.brand { width: 100%; }
|
|
|
|
|
|
|
|
.brand-copy { flex-direction: column; gap: 0; }
|
|
|
|
|
|
|
|
.project-name { max-width: calc(100vw - 96px); }
|
|
|
|
|
|
|
|
.board { grid-template-columns: 1fr; }
|
|
|
|
|
|
|
|
.nav { flex: 1; }
|
|
|
|
|
|
|
|
.nav-link { flex: 1; justify-content: center; }
|
|
|
|
|
|
|
|
.sse-status { margin-left: auto; }
|
|
|
|
|
|
|
|
.row { align-items: flex-start; flex-direction: column; gap: 4px; }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
|
|
|
*, *::before, *::after {
|
|
|
|
|
|
|
|
transition-duration: 0.01ms !important;
|
|
|
|
|
|
|
|
animation-duration: 0.01ms !important;
|
|
|
|
|
|
|
|
animation-iteration-count: 1 !important;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|
|
|
|
</style>
|
|
|
|
</head>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<body>
|
|
|
|
<header class="app-header">
|
|
|
|
<div class="topbar">
|
|
|
|
<div class="brand">
|
|
|
|
<span class="dot" id="conn"></span>
|
|
|
|
<span class="mark" aria-hidden="true">
|
|
|
|
<h1>AgentHub Board</h1>
|
|
|
|
<svg viewBox="0 0 24 24" fill="none">
|
|
|
|
<span class="spacer"></span>
|
|
|
|
<path d="M5 7.5h8.5a5.5 5.5 0 0 1 0 11H5v-11Z" stroke="#58A6FF" stroke-width="1.8" />
|
|
|
|
<span class="meta" id="meta">loading…</span>
|
|
|
|
<path d="M8.5 5.5h7a4 4 0 0 1 0 8h-7v-8Z" stroke="#22C55E" stroke-width="1.8" />
|
|
|
|
</div>
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
<div class="brand-copy">
|
|
|
|
|
|
|
|
<span class="brand-title">AgentHub</span>
|
|
|
|
|
|
|
|
<span class="project-name" id="projectName">${projectNameHtml}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<span class="header-spacer"></span>
|
|
|
|
|
|
|
|
<nav class="nav" aria-label="Primary">
|
|
|
|
|
|
|
|
<a class="nav-link active" href="/board">Board</a>
|
|
|
|
|
|
|
|
<a class="nav-link" href="/team">Team</a>
|
|
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
<span class="sse-status stale" id="sseStatus">
|
|
|
|
|
|
|
|
<span class="conn-dot" aria-hidden="true"></span>
|
|
|
|
|
|
|
|
<span id="sseLabel">connecting</span>
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<main class="board" id="board">
|
|
|
|
<main class="board" id="board">
|
|
|
|
${columnSkeleton()}
|
|
|
|
${columnSkeleton()}
|
|
|
|
@ -450,100 +212,36 @@ ${columnSkeleton()}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
<script>
|
|
|
|
var REFRESH_MS = 6000;
|
|
|
|
var REFRESH_MS = 4000;
|
|
|
|
var TIMER_MS = 1000;
|
|
|
|
|
|
|
|
var COLUMNS = ${JSON.stringify(BOARD_COLUMNS.map((c) => c.key))};
|
|
|
|
var COLUMNS = ${JSON.stringify(BOARD_COLUMNS.map((c) => c.key))};
|
|
|
|
var PROJECT_NAME = ${initialProjectName};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function esc(s) {
|
|
|
|
function esc(s) {
|
|
|
|
return String(s == null ? '' : s)
|
|
|
|
return String(s == null ? '' : s)
|
|
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
.replace(/"/g, '"');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function compactDuration(ms) {
|
|
|
|
|
|
|
|
var s = Math.max(0, Math.floor(ms / 1000));
|
|
|
|
|
|
|
|
if (s < 60) return s + 's';
|
|
|
|
|
|
|
|
var m = Math.floor(s / 60);
|
|
|
|
|
|
|
|
if (m < 60) return m + 'm';
|
|
|
|
|
|
|
|
var h = Math.floor(m / 60);
|
|
|
|
|
|
|
|
if (h < 48) return h + 'h';
|
|
|
|
|
|
|
|
return Math.floor(h / 24) + 'd';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function ago(iso) {
|
|
|
|
function ago(iso) {
|
|
|
|
var t = Date.parse(iso);
|
|
|
|
var t = Date.parse(iso);
|
|
|
|
if (isNaN(t)) return '';
|
|
|
|
if (isNaN(t)) return '';
|
|
|
|
return compactDuration(Date.now() - t) + ' ago';
|
|
|
|
var s = Math.max(0, (Date.now() - t) / 1000);
|
|
|
|
|
|
|
|
if (s < 60) return Math.floor(s) + 's ago';
|
|
|
|
|
|
|
|
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
|
|
|
|
|
|
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
|
|
|
|
|
|
return Math.floor(s / 86400) + 'd ago';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function msToHuman(ms) {
|
|
|
|
function msToHuman(ms) {
|
|
|
|
if (ms < 1000) return ms + 'ms';
|
|
|
|
if (ms < 1000) return ms + 'ms';
|
|
|
|
return compactDuration(ms);
|
|
|
|
if (ms < 60000) return Math.round(ms / 1000) + 's';
|
|
|
|
|
|
|
|
if (ms < 3600000) return Math.round(ms / 60000) + 'm';
|
|
|
|
|
|
|
|
return Math.round(ms / 3600000) + 'h';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
async function getJSON(path) {
|
|
|
|
async function getJSON(path) {
|
|
|
|
var res = await fetch(path, { headers: { accept: 'application/json' } });
|
|
|
|
var res = await fetch(path, { headers: { accept: 'application/json' } });
|
|
|
|
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
|
|
|
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
|
|
|
return res.json();
|
|
|
|
return res.json();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function statusLabel(status) {
|
|
|
|
|
|
|
|
return String(status || 'open').replace(/_/g, ' ');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function hashColor(name) {
|
|
|
|
|
|
|
|
var colors = ['#0EA5E9', '#14B8A6', '#F59E0B', '#EF4444', '#8B5CF6', '#64748B'];
|
|
|
|
|
|
|
|
var h = 0;
|
|
|
|
|
|
|
|
var s = String(name || '');
|
|
|
|
|
|
|
|
for (var i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
|
|
|
|
|
|
return colors[Math.abs(h) % colors.length];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function agentSpec(name) {
|
|
|
|
|
|
|
|
var key = String(name || '').toLowerCase();
|
|
|
|
|
|
|
|
var map = {
|
|
|
|
|
|
|
|
claude: { color: '#D97757', initials: 'C', architect: true },
|
|
|
|
|
|
|
|
codex: { color: '#10A37F', initials: 'Cx' },
|
|
|
|
|
|
|
|
kimi: { color: '#7C3AED', initials: 'K' },
|
|
|
|
|
|
|
|
'windows-claude': { color: '#2563EB', initials: 'W' },
|
|
|
|
|
|
|
|
backyard: { color: '#64748B', initials: 'B' }
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
if (map[key]) return map[key];
|
|
|
|
|
|
|
|
var clean = key.replace(/[^a-z0-9]+/g, ' ').trim();
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
color: hashColor(key),
|
|
|
|
|
|
|
|
initials: (clean ? clean.split(' ').map(function(p) { return p[0]; }).join('').slice(0, 2) : '?').toUpperCase()
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function agentAvatar(name, role) {
|
|
|
|
|
|
|
|
if (!name) return '<span class="avatar" style="--agent-color:#64748B"><span class="avatar-core">?</span><span class="avatar-label">unassigned</span></span>';
|
|
|
|
|
|
|
|
var spec = agentSpec(name);
|
|
|
|
|
|
|
|
var isArchitect = spec.architect || String(role || '').toLowerCase() === 'architect';
|
|
|
|
|
|
|
|
return '<span class="avatar' + (isArchitect ? ' architect' : '') + '" style="--agent-color:' + esc(spec.color) + '">' +
|
|
|
|
|
|
|
|
'<span class="avatar-core">' + esc(spec.initials) + '</span>' +
|
|
|
|
|
|
|
|
'<span class="avatar-label">@' + esc(name) + '</span>' +
|
|
|
|
|
|
|
|
'</span>';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function projectTag(title) {
|
|
|
|
|
|
|
|
var s = String(title || '').toLowerCase();
|
|
|
|
|
|
|
|
if (s.indexOf('win') >= 0 || s.indexOf('windows') >= 0) return 'windows';
|
|
|
|
|
|
|
|
if (s.indexOf('backend') >= 0 || s.indexOf('api') >= 0) return 'backend';
|
|
|
|
|
|
|
|
if (s.indexOf('magic') >= 0) return 'magic';
|
|
|
|
|
|
|
|
if (s.indexOf('agenthub') >= 0 || s.indexOf('board') >= 0 || s.indexOf('ui') >= 0) return 'agenthub';
|
|
|
|
|
|
|
|
return PROJECT_NAME;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function timerLabel(status, createdAt, updatedAt) {
|
|
|
|
|
|
|
|
var created = Date.parse(createdAt);
|
|
|
|
|
|
|
|
var updated = Date.parse(updatedAt || createdAt);
|
|
|
|
|
|
|
|
if (isNaN(created)) return '';
|
|
|
|
|
|
|
|
var key = String(status || 'open');
|
|
|
|
|
|
|
|
if (key === 'done' || key === 'cancelled') {
|
|
|
|
|
|
|
|
return 'total ' + compactDuration((isNaN(updated) ? Date.now() : updated) - created);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (key === 'in_progress') return 'claimed ' + ago(updatedAt || createdAt);
|
|
|
|
|
|
|
|
if (key === 'review') return 'review ' + ago(updatedAt || createdAt);
|
|
|
|
|
|
|
|
return 'created ' + ago(createdAt);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function updateTimers() {
|
|
|
|
|
|
|
|
document.querySelectorAll('[data-timer]').forEach(function(el) {
|
|
|
|
|
|
|
|
el.textContent = timerLabel(el.dataset.status, el.dataset.created, el.dataset.updated);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Activity timeline ──────────────────────────────────────────────────
|
|
|
|
function renderTimelineItems(items) {
|
|
|
|
function renderTimelineItems(items) {
|
|
|
|
if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>';
|
|
|
|
if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>';
|
|
|
|
return items.map(function(item) {
|
|
|
|
return items.map(function(item) {
|
|
|
|
@ -565,87 +263,83 @@ ${columnSkeleton()}
|
|
|
|
'</div>';
|
|
|
|
'</div>';
|
|
|
|
}).join('');
|
|
|
|
}).join('');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadTimeline(card, id) {
|
|
|
|
function loadTimeline(card, id) {
|
|
|
|
var tl = card.querySelector('.timeline');
|
|
|
|
var tl = card.querySelector('.timeline');
|
|
|
|
if (!tl) return;
|
|
|
|
if (!tl) return;
|
|
|
|
tl.innerHTML = '<div class="tl-loading">loading...</div>';
|
|
|
|
tl.innerHTML = '<div class="tl-loading">loading…</div>';
|
|
|
|
getJSON('/tasks/' + encodeURIComponent(id) + '/activity')
|
|
|
|
getJSON('/tasks/' + encodeURIComponent(id) + '/activity')
|
|
|
|
.then(function(items) { tl.innerHTML = renderTimelineItems(items); })
|
|
|
|
.then(function(items) { tl.innerHTML = renderTimelineItems(items); })
|
|
|
|
.catch(function() { tl.innerHTML = '<div class="tl-empty">could not load activity</div>'; });
|
|
|
|
.catch(function() { tl.innerHTML = '<div class="tl-empty">could not load activity</div>'; });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Delegate click handling to the board container so it survives re-renders.
|
|
|
|
document.getElementById('board').addEventListener('click', function(e) {
|
|
|
|
document.getElementById('board').addEventListener('click', function(e) {
|
|
|
|
var card = e.target.closest('.card[data-id]');
|
|
|
|
var card = e.target.closest('.card[data-id]');
|
|
|
|
if (!card) return;
|
|
|
|
if (!card) return;
|
|
|
|
var id = card.dataset.id;
|
|
|
|
var id = card.dataset.id;
|
|
|
|
var wasExpanded = card.classList.contains('expanded');
|
|
|
|
var wasExpanded = card.classList.contains('expanded');
|
|
|
|
card.classList.toggle('expanded');
|
|
|
|
card.classList.toggle('expanded');
|
|
|
|
|
|
|
|
// Only fetch when opening (not on every toggle so cached content stays).
|
|
|
|
if (!wasExpanded) loadTimeline(card, id);
|
|
|
|
if (!wasExpanded) loadTimeline(card, id);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
document.getElementById('board').addEventListener('keydown', function(e) {
|
|
|
|
|
|
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
|
|
|
|
|
|
var card = e.target.closest('.card[data-id]');
|
|
|
|
|
|
|
|
if (!card) return;
|
|
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
card.click();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Task card rendering ────────────────────────────────────────────────
|
|
|
|
function taskCard(t) {
|
|
|
|
function taskCard(t) {
|
|
|
|
var status = byStatus(t.status);
|
|
|
|
var tags = '';
|
|
|
|
return '<article class="card" tabindex="0" data-id="' + esc(t.id) + '">' +
|
|
|
|
if (t.role) tags += '<span class="badge role">' + esc(t.role) + '</span>';
|
|
|
|
'<div class="card-top">' +
|
|
|
|
if (t.assignedTo) tags += '<span class="badge agent">@' + esc(t.assignedTo) + '</span>';
|
|
|
|
'<span class="id">' + esc(t.id) + '</span>' +
|
|
|
|
return '<div class="card" data-id="' + esc(t.id) + '">' +
|
|
|
|
'<span class="pill status-pill status-' + esc(status) + '">' + esc(statusLabel(status)) + '</span>' +
|
|
|
|
'<div class="card-head">' +
|
|
|
|
'</div>' +
|
|
|
|
'<div class="id">' + esc(t.id) + '</div>' +
|
|
|
|
'<h3 class="title">' + esc(t.title) + '</h3>' +
|
|
|
|
'<div class="title">' + esc(t.title) + '</div>' +
|
|
|
|
'<div class="meta-row">' +
|
|
|
|
(tags ? '<div class="tags">' + tags + '</div>' : '') +
|
|
|
|
'<span class="pill project-tag">' + esc(projectTag(t.title)) + '</span>' +
|
|
|
|
|
|
|
|
agentAvatar(t.assignedTo, t.role) +
|
|
|
|
|
|
|
|
'<span class="pill timer-badge" data-timer data-status="' + esc(status) + '" data-created="' + esc(t.createdAt) + '" data-updated="' + esc(t.updatedAt) + '">' +
|
|
|
|
|
|
|
|
esc(timerLabel(status, t.createdAt, t.updatedAt)) +
|
|
|
|
|
|
|
|
'</span>' +
|
|
|
|
|
|
|
|
'</div>' +
|
|
|
|
'</div>' +
|
|
|
|
'<div class="timeline"></div>' +
|
|
|
|
'<div class="timeline"></div>' +
|
|
|
|
'</article>';
|
|
|
|
'</div>';
|
|
|
|
}
|
|
|
|
|
|
|
|
function byStatus(status) {
|
|
|
|
|
|
|
|
return COLUMNS.indexOf(status) >= 0 ? status : 'open';
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function renderBoard(tasks) {
|
|
|
|
function renderBoard(tasks) {
|
|
|
|
var byCol = {};
|
|
|
|
var byCol = {};
|
|
|
|
COLUMNS.forEach(function(k) { byCol[k] = []; });
|
|
|
|
COLUMNS.forEach(function (k) { byCol[k] = []; });
|
|
|
|
(tasks || []).forEach(function(t) {
|
|
|
|
(tasks || []).forEach(function (t) {
|
|
|
|
byCol[byStatus(t.status)].push(t);
|
|
|
|
var k = byCol[t.status] ? t.status : 'open';
|
|
|
|
|
|
|
|
byCol[k].push(t);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
COLUMNS.forEach(function(k) {
|
|
|
|
COLUMNS.forEach(function (k) {
|
|
|
|
var list = byCol[k];
|
|
|
|
var list = byCol[k];
|
|
|
|
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
|
|
|
|
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
|
|
|
|
var count = document.querySelector('[data-count="' + k + '"]');
|
|
|
|
var count = document.querySelector('[data-count="' + k + '"]');
|
|
|
|
if (count) count.textContent = String(list.length);
|
|
|
|
if (count) count.textContent = String(list.length);
|
|
|
|
if (!cardsEl) return;
|
|
|
|
if (!cardsEl) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Preserve expanded state: collect which IDs are expanded before re-render.
|
|
|
|
var expanded = {};
|
|
|
|
var expanded = {};
|
|
|
|
cardsEl.querySelectorAll('.card.expanded[data-id]').forEach(function(c) {
|
|
|
|
cardsEl.querySelectorAll('.card.expanded[data-id]').forEach(function(c) {
|
|
|
|
expanded[c.dataset.id] = true;
|
|
|
|
expanded[c.dataset.id] = true;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
cardsEl.innerHTML = list.length ? list.map(taskCard).join('') : '<div class="empty">none</div>';
|
|
|
|
|
|
|
|
|
|
|
|
cardsEl.innerHTML = list.length
|
|
|
|
|
|
|
|
? list.map(taskCard).join('')
|
|
|
|
|
|
|
|
: '<div class="empty">—</div>';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Re-expand any cards that were open, but do NOT re-fetch; keep old content.
|
|
|
|
Object.keys(expanded).forEach(function(id) {
|
|
|
|
Object.keys(expanded).forEach(function(id) {
|
|
|
|
var card = cardsEl.querySelector('.card[data-id="' + id + '"]');
|
|
|
|
var card = cardsEl.querySelector('.card[data-id="' + id + '"]');
|
|
|
|
if (card) card.classList.add('expanded');
|
|
|
|
if (card) card.classList.add('expanded');
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
updateTimers();
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function handoffRoute(h) {
|
|
|
|
function handoffRoute(h) {
|
|
|
|
var from = '<span class="who">' + esc(h.fromRole || '?') + '</span>';
|
|
|
|
var from = esc(h.fromRole || '?');
|
|
|
|
var to = '<span class="who">' + esc(h.toRole || '?') + '</span>';
|
|
|
|
var to = esc(h.toRole || '?');
|
|
|
|
if (h.fromAgent) from += agentAvatar(h.fromAgent, h.fromRole);
|
|
|
|
if (h.fromAgent) from += '<span class="badge agent">@' + esc(h.fromAgent) + '</span>';
|
|
|
|
if (h.toAgent) to += agentAvatar(h.toAgent, h.toRole);
|
|
|
|
if (h.toAgent) to += '<span class="badge agent">@' + esc(h.toAgent) + '</span>';
|
|
|
|
return from + '<span class="id">to</span>' + to;
|
|
|
|
return '<span class="who">' + from + ' → ' + to + '</span>';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function renderHandoffs(items) {
|
|
|
|
function renderHandoffs(items) {
|
|
|
|
var el = document.getElementById('handoffs');
|
|
|
|
var el = document.getElementById('handoffs');
|
|
|
|
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
|
|
|
|
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
|
|
|
|
el.innerHTML = items.slice(0, 12).map(function(h) {
|
|
|
|
el.innerHTML = items.slice(0, 12).map(function (h) {
|
|
|
|
return '<div class="row">' +
|
|
|
|
return '<div class="row">' +
|
|
|
|
'<span class="id">' + esc(h.id) + '</span>' +
|
|
|
|
'<span class="id">' + esc(h.id) + '</span>' +
|
|
|
|
'<span class="what">' + esc(h.title) + '</span>' +
|
|
|
|
'<span class="what">' + esc(h.title) + '</span>' +
|
|
|
|
@ -657,7 +351,7 @@ ${columnSkeleton()}
|
|
|
|
function renderDecisions(items) {
|
|
|
|
function renderDecisions(items) {
|
|
|
|
var el = document.getElementById('decisions');
|
|
|
|
var el = document.getElementById('decisions');
|
|
|
|
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
|
|
|
|
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
|
|
|
|
el.innerHTML = items.slice(0, 12).map(function(d) {
|
|
|
|
el.innerHTML = items.slice(0, 12).map(function (d) {
|
|
|
|
var st = d.status ? '<span class="badge">' + esc(d.status) + '</span>' : '';
|
|
|
|
var st = d.status ? '<span class="badge">' + esc(d.status) + '</span>' : '';
|
|
|
|
return '<div class="row">' +
|
|
|
|
return '<div class="row">' +
|
|
|
|
'<span class="id">' + esc(d.id) + '</span>' +
|
|
|
|
'<span class="id">' + esc(d.id) + '</span>' +
|
|
|
|
@ -666,25 +360,9 @@ ${columnSkeleton()}
|
|
|
|
'</div>';
|
|
|
|
'</div>';
|
|
|
|
}).join('');
|
|
|
|
}).join('');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function setConn(state, label) {
|
|
|
|
function setConn(state) {
|
|
|
|
var el = document.getElementById('sseStatus');
|
|
|
|
var dot = document.getElementById('conn');
|
|
|
|
var text = document.getElementById('sseLabel');
|
|
|
|
dot.className = 'dot' + (state === 'ok' ? '' : state === 'stale' ? ' stale' : ' down');
|
|
|
|
el.className = 'sse-status ' + (state === 'ok' ? '' : state === 'stale' ? 'stale' : 'down');
|
|
|
|
|
|
|
|
text.textContent = label || (state === 'ok' ? 'connected' : state === 'stale' ? 'connecting' : 'offline');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function applyProjectName(name) {
|
|
|
|
|
|
|
|
PROJECT_NAME = name || PROJECT_NAME;
|
|
|
|
|
|
|
|
document.getElementById('projectName').textContent = PROJECT_NAME;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
async function loadStatusMeta() {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
var status = await getJSON('/status');
|
|
|
|
|
|
|
|
var body = String(status && status.body ? status.body : '');
|
|
|
|
|
|
|
|
var m = body.match(/^#\\s+AgentHub\\s+[\\u2014-]\\s+(.+)$/m) || body.match(/^#\\s+Project\\s+Status\\s*:?\\s*(.+)$/m);
|
|
|
|
|
|
|
|
applyProjectName(m && m[1] ? m[1].trim() : PROJECT_NAME);
|
|
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
|
|
applyProjectName(PROJECT_NAME);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
async function refresh() {
|
|
|
|
async function refresh() {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
@ -692,38 +370,16 @@ ${columnSkeleton()}
|
|
|
|
renderBoard(r[0]);
|
|
|
|
renderBoard(r[0]);
|
|
|
|
renderHandoffs(r[1]);
|
|
|
|
renderHandoffs(r[1]);
|
|
|
|
renderDecisions(r[2]);
|
|
|
|
renderDecisions(r[2]);
|
|
|
|
setConn('ok', eventSourceReady ? 'connected' : 'polling');
|
|
|
|
setConn('ok');
|
|
|
|
} catch (_) {
|
|
|
|
document.getElementById('meta').textContent =
|
|
|
|
setConn('down', 'offline');
|
|
|
|
(r[0] || []).length + ' tasks · updated ' + new Date().toLocaleTimeString();
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
setConn('down');
|
|
|
|
|
|
|
|
document.getElementById('meta').textContent = 'disconnected — retrying…';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
var eventSourceReady = false;
|
|
|
|
|
|
|
|
function connectEvents() {
|
|
|
|
|
|
|
|
if (!('EventSource' in window)) {
|
|
|
|
|
|
|
|
setConn('stale', 'polling');
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
var source = new EventSource('/events');
|
|
|
|
|
|
|
|
source.onopen = function() {
|
|
|
|
|
|
|
|
eventSourceReady = true;
|
|
|
|
|
|
|
|
setConn('ok', 'connected');
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
source.onmessage = function() {
|
|
|
|
|
|
|
|
eventSourceReady = true;
|
|
|
|
|
|
|
|
setConn('ok', 'connected');
|
|
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
source.onerror = function() {
|
|
|
|
|
|
|
|
eventSourceReady = false;
|
|
|
|
|
|
|
|
setConn('stale', 'reconnecting');
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
loadStatusMeta();
|
|
|
|
|
|
|
|
refresh();
|
|
|
|
refresh();
|
|
|
|
connectEvents();
|
|
|
|
|
|
|
|
setInterval(refresh, REFRESH_MS);
|
|
|
|
setInterval(refresh, REFRESH_MS);
|
|
|
|
setInterval(updateTimers, TIMER_MS);
|
|
|
|
|
|
|
|
</script>
|
|
|
|
</script>
|
|
|
|
</body>
|
|
|
|
</body>
|
|
|
|
</html>
|
|
|
|
</html>
|
|
|
|
|