diff --git a/src/server/team.ts b/src/server/team.ts index b4cc040..3a2af55 100644 --- a/src/server/team.ts +++ b/src/server/team.ts @@ -1,12 +1,14 @@ /** * Team org-chart page, served at `GET /team`. * - * Roster-driven: roles, model + provider come from `config.agents` (the named - * team roster). Rendered as a real org chart — the architect at the top, every - * worker below — with an SVG connector layer whose lines carry a Paperclip-style - * "traveling pulse": a glowing green capsule flows from the architect down each - * edge and lights up the node it reaches (onArrive → active), looping. Free/busy - * is live via SSE; a busy agent stays lit and its edge pulses brighter. + * Roster-driven org chart: the architect at the root, every worker below, with + * an SVG connector layer. The green "traveling pulse" is EVENT-DRIVEN — it is + * not a decorative loop. It visualises real task flow: + * • delegation (a task goes to an agent) → pulse flows architect → agent, + * and the agent's card turns "active" (green ring + badge); + * • review (an agent submits work) → amber pulse flows agent → architect. + * State (active / reviewing / free) is live via SSE. At rest the chart only + * breathes faintly — motion means something happened. */ import { loadConfig } from '../core/config.js'; @@ -22,7 +24,8 @@ interface RosterAgent { model?: string; kind?: string; description?: string; - busyTask?: TaskRow; + state?: 'active' | 'reviewing'; + stateTask?: TaskRow; } const ROLE_ORDER: Record = { architect: 0, implementer: 1, reviewer: 2, tester: 3 }; @@ -42,56 +45,72 @@ function gatherRoster(cwd: string): RosterAgent[] { return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role })); } -function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] { +/** Attach live state: in_progress → active, else a submitted-for-review task → reviewing. */ +function attachState(cwd: string, roster: RosterAgent[]): RosterAgent[] { const tasks = listTasks(cwd) as TaskRow[]; - const busyByAgent = new Map(); + const active = new Map(); + const review = new Map(); for (const t of tasks) { - if (t.status === 'in_progress' && t.assignedTo) { - const existing = busyByAgent.get(t.assignedTo); - if (!existing || t.updatedAt > existing.updatedAt) busyByAgent.set(t.assignedTo, t); + if (!t.assignedTo) continue; + if (t.status === 'in_progress') { + const e = active.get(t.assignedTo); + if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); + } else if (t.status === 'review') { + const e = review.get(t.assignedTo); + if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); } } - return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) })); + return roster.map((a) => { + if (active.has(a.name)) return { ...a, state: 'active', stateTask: active.get(a.name) }; + if (review.has(a.name)) return { ...a, state: 'reviewing', stateTask: review.get(a.name) }; + return a; + }); } -/** Monogram fallback when there is no provider logo for the node icon. */ function monogram(name: string): string { const clean = name.replace(/[^A-Za-z0-9]+/g, ' ').trim(); - const initials = clean - ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() - : '?'; - return escapeHtml(initials); + return escapeHtml(clean ? clean.split(' ').map((p) => p[0]).join('').slice(0, 2).toUpperCase() : '?'); +} + +function statusMarkup(a: RosterAgent): string { + if (a.state === 'active' && a.stateTask) { + return `${escapeHtml(a.stateTask.id)} · active`; + } + if (a.state === 'reviewing' && a.stateTask) { + return `${escapeHtml(a.stateTask.id)} · in review`; + } + return `free`; } -/** One org-chart node (kept class `agent-card` + `data-agent` for the live sync). */ function node(a: RosterAgent, isArchitect = false): string { const meta = providerMeta(a.kind); - const logo = a.kind ? providerLogo(a.kind, 18) : ''; - const icon = logo || `${monogram(a.name)}`; - const busy = !!a.busyTask; - const status = busy - ? `${escapeHtml(a.busyTask!.id)} · claimed …` - : `free`; + const icon = (a.kind ? providerLogo(a.kind, 18) : '') || `${monogram(a.name)}`; const modelColor = meta ? meta.color : 'var(--muted)'; - return `
+ const badge = + a.state === 'active' + ? 'active' + : a.state === 'reviewing' + ? 'review' + : ''; + const cls = `node agent-card${isArchitect ? ' architect' : ''}${a.state ? ` ${a.state}` : ''}`; + return `
${icon} ${escapeHtml(a.name)} ${escapeHtml(a.role)}${a.model ? ` · ${escapeHtml(a.model)}` : ''} + ${badge}
-
${status}
+
${statusMarkup(a)}
`; } export function renderTeamHtml(cwd: string): string { const config = loadConfig(cwd); - const roster = attachBusy(cwd, gatherRoster(cwd)); + const roster = attachState(cwd, gatherRoster(cwd)); - const architects = roster - .filter((a) => a.role === 'architect') - .sort((a, b) => a.name.localeCompare(b.name)); + const architects = roster.filter((a) => a.role === 'architect').sort((a, b) => a.name.localeCompare(b.name)); const workers = roster .filter((a) => a.role !== 'architect') .sort( @@ -100,15 +119,9 @@ export function renderTeamHtml(cwd: string): string { (PROVIDER_ORDER[a.kind ?? ''] ?? 9) - (PROVIDER_ORDER[b.kind ?? ''] ?? 9) || a.name.localeCompare(b.name), ); - - // If no architect is configured, promote the first roster entry so the tree - // still has a root to pulse from. const root = architects.length ? architects : workers.slice(0, 1); const rest = architects.length ? workers : workers.slice(1); - const archRow = root.map((a) => node(a, true)).join(''); - const workerRow = rest.map((a) => node(a)).join(''); - return ` @@ -126,12 +139,12 @@ export function renderTeamHtml(cwd: string): string { .node { position: relative; - min-width: 210px; max-width: 260px; + min-width: 214px; max-width: 264px; background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(0,0,0,.10)), var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 12px 14px; - transition: border-color 180ms ease, transform 180ms ease, box-shadow 260ms ease, background 260ms ease; + transition: border-color 220ms ease, transform 180ms ease, box-shadow 320ms ease, background 320ms ease; animation: nodeEnter 300ms cubic-bezier(.2,.7,.2,1) both; } @keyframes nodeEnter { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } @@ -141,10 +154,7 @@ export function renderTeamHtml(cwd: string): string { .node-icon { width: 34px; height: 34px; flex: 0 0 auto; display: inline-grid; place-items: center; - border-radius: 10px; - background: var(--raised); - border: 1px solid var(--border); - box-shadow: inset 0 0 0 1px rgba(255,255,255,.02); + border-radius: 10px; background: var(--raised); border: 1px solid var(--border); } .node-icon .mono { font: 800 12px/1 var(--font-mono); color: var(--pv, var(--muted)); } .node-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; } @@ -152,28 +162,37 @@ export function renderTeamHtml(cwd: string): string { .node-role { font: 11px/1.2 var(--font-mono); color: var(--muted); white-space: nowrap; text-transform: uppercase; letter-spacing: .04em; } .node-model { text-transform: none; letter-spacing: 0; } + .node-badge { margin-left: auto; align-self: flex-start; font: 700 9.5px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; padding: 3px 7px; border-radius: 999px; } + .node-badge:empty { display: none; } + .node-badge.active { color: #adf2c7; background: rgba(34,197,94,.16); border: 1px solid rgba(34,197,94,.4); } + .node-badge.review { color: #f2d59b; background: rgba(210,153,34,.16); border: 1px solid rgba(210,153,34,.4); } + .agent-status { margin-top: 10px; display: flex; align-items: center; gap: 8px; font-size: 12px; } .st-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: 0 0 auto; } - .st-dot.busy { background: var(--status-in_progress); box-shadow: 0 0 0 3px rgba(88,166,255,.16); animation: dotPulse 1.5s ease-in-out infinite; } + .st-dot.busy { background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); animation: dotPulse 1.5s ease-in-out infinite; } + .st-dot.review { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.16); } @keyframes dotPulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } } .st-free { color: var(--muted); } - .st-link { color: var(--accent); text-decoration: none; font-family: var(--font-mono); font-size: 12px; } + .st-link { color: var(--green); text-decoration: none; font-family: var(--font-mono); font-size: 12px; } + .st-link.review { color: var(--status-review); } .st-link:hover { text-decoration: underline; } - /* Node lights up when a pulse arrives (onArrive) and while busy. */ - .node.arrive { border-color: var(--green); box-shadow: 0 0 0 1px rgba(34,197,94,.5), 0 0 26px rgba(34,197,94,.16); } - .node.busy { border-color: rgba(88,166,255,.55); animation: nodeEnter 300ms both, busyGlow 2.6s ease-in-out infinite .3s; } - @keyframes busyGlow { - 0%,100% { box-shadow: 0 0 0 1px rgba(88,166,255,.18); } - 50% { box-shadow: 0 0 0 1px rgba(88,166,255,.44), 0 6px 26px rgba(88,166,255,.14); } - } + /* Live states + pulse-arrival pop. */ + .node.active { border-color: rgba(34,197,94,.6); box-shadow: 0 0 0 1px rgba(34,197,94,.5), 0 0 24px rgba(34,197,94,.15); } + .node.reviewing { border-color: rgba(210,153,34,.55); box-shadow: 0 0 0 1px rgba(210,153,34,.45), 0 0 20px rgba(210,153,34,.12); } + .node.arrive { animation: arrivePop 560ms ease; } + @keyframes arrivePop { 0% { box-shadow: 0 0 0 0 rgba(34,197,94,.6); } 100% { box-shadow: 0 0 0 15px rgba(34,197,94,0); } } - /* Connector paths + the traveling pulse. */ - .edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; opacity: .8; } - .edge-pulse { fill: none; stroke: var(--green); stroke-width: 3; stroke-linecap: round; filter: url(#pulseGlow); } + /* Connectors: static faint lines that breathe subtly at rest. */ + .edge-base { fill: none; stroke: var(--border); stroke-width: 1.5; animation: edgeBreath 4.5s ease-in-out infinite; } + @keyframes edgeBreath { 0%,100% { opacity: .38; } 50% { opacity: .7; } } + /* The event pulse — created on demand, removed when it arrives. */ + .edge-pulse { fill: none; stroke-width: 3.5; stroke-linecap: round; filter: url(#pulseGlow); } + .edge-pulse.down { stroke: var(--green); } + .edge-pulse.up { stroke: var(--status-review); } @media (max-width: 600px) { .node { min-width: 0; width: 100%; max-width: none; } .tier.arch { margin-bottom: 40px; } } - @media (prefers-reduced-motion: reduce) { .edge-pulse { display: none; } } + @media (prefers-reduced-motion: reduce) { .edge-pulse, .edge-base { animation: none; } } @@ -182,107 +201,138 @@ export function renderTeamHtml(cwd: string): string { -
${archRow}
-
${workerRow}
+
${root.map((a) => node(a, true)).join('')}
+
${rest.map((a) => node(a)).join('')}