agenthub/src/server/ui-shared.ts
chahinebrini 4672677bf5 feat(team): roster-driven team page — provider grouping + brand logos (TSK-0028)
Architect implementation of the reopened TSK-0028 (option B). The team page now
reads config.agents (the roster) as the source of truth instead of inferring
role/identity from tasks:
- one fixed role per agent (fixes "codex in architect+implementer+tester")
- implementers grouped by provider with official Simple Icons brand logos
  (Anthropic / OpenAI / Moonshot) + the model behind each agent (Opus 4.8,
  Sonnet 4.6, GPT-5 Codex, Kimi K2)
- demo/throwaway agents gone (not in the roster)
- architect tier (claude) on top, implementer provider columns, tester tier
  (ahmed); free/busy + live timer from in-progress tasks
- ui-shared: providerLogo()/providerMeta() with verified brand SVG paths

Bump 0.7.4 -> 0.7.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:24:00 +02:00

280 lines
11 KiB
TypeScript

/**
* Shared UI primitives for AgentHub HTML pages.
*
* Constraints:
* - Dependency-free: only string/template helpers, no npm UI libs.
* - No emojis: inline SVG/CSS only.
* - Self-contained: pages import this and inline the returned CSS/JS.
*/
import { TaskStatus as TaskStatusSchema } from '../core/schema.js';
type TaskStatus = 'open' | 'in_progress' | 'review' | 'done' | 'cancelled';
/** CSS variables block matching the AgentHub dark design spec. */
export function designTokensCss(): string {
return `
:root {
--bg: #0F172A;
--surface: #161B22;
--raised: #1E293B;
--border: #30363D;
--text: #F8FAFC;
--muted: #94A3B8;
--accent: #58A6FF;
--green: #22C55E;
--status-open: #8B949E;
--status-in_progress: #58A6FF;
--status-review: #D29922;
--status-done: #22C55E;
--status-cancelled: #6E7681;
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Monaco, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.5 var(--font-sans);
padding: 16px 20px 32px;
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
`;
}
const AGENT_PALETTE: Record<string, { color: string; initial: string }> = {
claude: { color: '#D97757', initial: 'C' },
codex: { color: '#10A37F', initial: 'Cx' },
kimi: { color: '#7C3AED', initial: 'K' },
'windows-claude': { color: '#2563EB', initial: 'W' },
backyard: { color: '#64748B', initial: 'B' },
};
function hashString(str: string): number {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
h |= 0;
}
return Math.abs(h);
}
function deterministicColor(name: string): string {
const colors = ['#DC2626', '#EA580C', '#D97706', '#65A30D', '#0891B2', '#2563EB', '#7C3AED', '#DB2777'];
return colors[hashString(name) % colors.length];
}
export interface AgentAvatarOptions {
/** Render an extra ring for the architect role. */
architectRing?: boolean;
size?: number;
}
/**
* Render a round agent avatar chip with per-agent accent color and initials.
* No external images, no emojis.
*/
export function agentAvatar(
name: string | undefined,
options: AgentAvatarOptions = {},
): string {
const resolvedName = name?.toLowerCase() ?? '';
const spec = AGENT_PALETTE[resolvedName] ?? {
color: deterministicColor(resolvedName || 'unknown'),
initial: (name ?? '?').slice(0, 1).toUpperCase(),
};
const { architectRing = false, size = 22 } = options;
const fontSize = Math.round(size * 0.45);
const ring = architectRing
? `box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px ${spec.color};`
: '';
return `<span class="agent-avatar" title="${escapeHtml(name ?? 'unknown')}" style="
display: inline-flex;
align-items: center;
justify-content: center;
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${spec.color};
color: #fff;
font-family: var(--font-mono);
font-size: ${fontSize}px;
font-weight: 700;
line-height: 1;
flex: 0 0 auto;
${ring}
">${escapeHtml(spec.initial)}</span>`;
}
/**
* Provider/company brand metadata + official Simple Icons SVG paths (verified,
* 24x24 viewBox). Used to group + badge agents by the model behind them.
*/
const PROVIDERS: Record<string, { name: string; color: string; path: string }> = {
anthropic: {
name: 'Anthropic',
color: '#D97757',
path: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
},
openai: {
name: 'OpenAI',
color: '#10A37F',
path: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
},
moonshot: {
name: 'Moonshot',
color: '#7C3AED',
path: 'm1.053 16.91 9.538 2.55a21 20.981 0 0 0 .06 2.031l5.956 1.592a12 11.99 0 0 1-15.554-6.172m-1.02-5.79 11.352 3.035a21 20.981 0 0 0-.469 2.01l10.817 2.89a12 11.99 0 0 1-1.845 2.004L.658 15.918a12 11.99 0 0 1-.625-4.796m1.593-5.146L13.573 9.17a21 20.981 0 0 0-1.01 1.874l11.297 3.02a21 20.981 0 0 1-.67 2.362l-11.55-3.087L.125 10.26a12 11.99 0 0 1 1.499-4.285ZM6.067 1.58l11.285 3.016a21 20.981 0 0 0-1.688 1.719l7.824 2.091a21 20.981 0 0 1 .513 2.664L2.107 5.218a12 11.99 0 0 1 3.96-3.638M21.68 4.866 7.222 1.003A12 11.99 0 0 1 21.68 4.866',
},
};
/** Brand metadata for a provider kind, or null if unknown. */
export function providerMeta(kind?: string): { name: string; color: string } | null {
if (!kind) return null;
const p = PROVIDERS[kind];
return p ? { name: p.name, color: p.color } : null;
}
/** Inline brand logo SVG for a provider kind, tinted with its brand color. */
export function providerLogo(kind: string | undefined, size = 20): string {
const p = kind ? PROVIDERS[kind] : undefined;
if (!p) return '';
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${p.color}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="flex:0 0 auto"><path d="${p.path}"/></svg>`;
}
/** Inline status pill for a task status. */
export function statusPill(status: TaskStatus): string {
const labels: Record<TaskStatus, string> = {
open: 'Open',
in_progress: 'In Progress',
review: 'Review',
done: 'Done',
cancelled: 'Cancelled',
};
const colorVar = `--status-${status}`;
return `<span class="status-pill" data-status="${status}" style="
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
font-family: var(--font-mono);
color: var(${colorVar});
border: 1px solid var(${colorVar});
background: transparent;
"><span style="width:6px;height:6px;border-radius:50%;background:var(${colorVar});"></span>${escapeHtml(labels[status])}</span>`;
}
/**
* CSS snippet that injects RGB versions of status colors so statusPill can use
* rgba() backgrounds/borders. Include this once in the page <style>.
*/
export function statusRgbCss(): string {
return `
.status-pill[data-status="open"] { --status-rgb: 139, 148, 158; }
.status-pill[data-status="in_progress"] { --status-rgb: 88, 166, 255; }
.status-pill[data-status="review"] { --status-rgb: 210, 153, 34; }
.status-pill[data-status="done"] { --status-rgb: 34, 197, 94; }
.status-pill[data-status="cancelled"] { --status-rgb: 110, 118, 129; }
`;
}
/** Escape HTML entities in a string. */
export function escapeHtml(raw: string): string {
return raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Client-side live timer snippet.
*
* Expects elements with `data-live-timer` and ISO timestamps in
* `data-timer-at`. The optional `data-timer-mode` controls the label prefix:
* - open -> "created"
* - in_progress -> "claimed"
* - review -> "review"
* - done -> "done"
*
* Updates every second.
*/
export function liveTimerJs(): string {
return `
(function() {
function formatAgo(iso) {
var t = Date.parse(iso);
if (isNaN(t)) return '';
var s = Math.max(0, Math.floor((Date.now() - t) / 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 < 24) return h + 'h';
return Math.floor(h / 24) + 'd';
}
function update() {
document.querySelectorAll('[data-live-timer]').forEach(function(el) {
var at = el.getAttribute('data-timer-at');
var mode = el.getAttribute('data-timer-mode') || 'open';
var prefix = { open: 'created', in_progress: 'claimed', review: 'review', done: 'done', cancelled: 'cancelled' }[mode] || 'updated';
el.textContent = prefix + ' ' + formatAgo(at) + ' ago';
});
}
update();
setInterval(update, 1000);
})();
`;
}
/** Common page header markup with AgentHub mark, project name and nav. */
export function pageHeader(projectName: string, current: 'board' | 'team'): string {
const mark = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="flex:0 0 auto"><circle cx="12" cy="12" r="10" stroke="var(--accent)" stroke-width="2.5"/><circle cx="12" cy="12" r="4" fill="var(--accent)"/></svg>`;
const navItem = (label: string, path: string, active: boolean) =>
`<a href="${path}" style="
text-decoration: none;
color: ${active ? 'var(--text)' : 'var(--muted)'};
font-weight: ${active ? '600' : '400'};
padding: 4px 8px;
border-radius: 6px;
border: 1px solid ${active ? 'var(--border)' : 'transparent'};
background: ${active ? 'var(--surface)' : 'transparent'};
">${label}</a>`;
return `
<header style="
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
">
${mark}
<h1 style="font-size:18px;margin:0;font-weight:600;">AgentHub</h1>
<span style="color:var(--muted);font-size:12px;">${escapeHtml(projectName)}</span>
<span style="flex:1;"></span>
<nav style="display:flex;gap:8px;align-items:center;">
${navItem('Board', '/board', current === 'board')}
${navItem('Team', '/team', current === 'team')}
</nav>
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
</header>`;
}