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>
This commit is contained in:
parent
b004e9fbb4
commit
4672677bf5
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.5",
|
||||
"description": "Local coordination layer for AI coding agents",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -116,7 +116,7 @@ async function runRemote(serverUrl: string, fn: () => Promise<void>): Promise<vo
|
||||
export function createProgram(cwd: string): Command {
|
||||
const program = new Command('agenthub')
|
||||
.description('Local coordination layer for AI coding agents')
|
||||
.version('0.7.4')
|
||||
.version('0.7.5')
|
||||
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||
|
||||
program
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Team hierarchy page, served at `GET /team`.
|
||||
*
|
||||
* Renders a role tree (architect on top, implementer/tester below) with one
|
||||
* card per configured agent. Free/busy state is derived from in-progress tasks.
|
||||
* Roster-driven: roles, model + provider come from `config.agents` (the named
|
||||
* team roster) — NOT inferred from whichever tasks an agent touched. So each
|
||||
* agent has exactly one role (no more "codex in three roles"), demo/throwaway
|
||||
* agents that aren't in the roster never show, and the model behind each agent
|
||||
* is explicit. Implementers are grouped by provider (Anthropic / OpenAI /
|
||||
* Moonshot) with the official brand logo. Free/busy is derived from in-progress
|
||||
* tasks. Falls back to the role config for projects without a roster.
|
||||
*/
|
||||
|
||||
import { loadConfig } from '../core/config.js';
|
||||
@ -13,187 +18,162 @@ import {
|
||||
escapeHtml,
|
||||
liveTimerJs,
|
||||
pageHeader,
|
||||
providerLogo,
|
||||
providerMeta,
|
||||
statusPill,
|
||||
} from './ui-shared.js';
|
||||
import type { Task } from '../core/schema.js';
|
||||
type TaskStatus = Task['status'];
|
||||
|
||||
interface AgentView {
|
||||
/** The task fields the index actually carries + that this page needs. */
|
||||
type TaskRow = Pick<Task, 'id' | 'title' | 'status' | 'assignedTo' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
interface RosterAgent {
|
||||
name: string;
|
||||
role: string;
|
||||
isArchitect: boolean;
|
||||
busyTask?: Task;
|
||||
model?: string;
|
||||
kind?: string;
|
||||
description?: string;
|
||||
busyTask?: TaskRow;
|
||||
}
|
||||
|
||||
const ROLE_ORDER: Record<string, number> = {
|
||||
architect: 0,
|
||||
implementer: 1,
|
||||
reviewer: 2,
|
||||
tester: 3,
|
||||
};
|
||||
const ROLE_ORDER: Record<string, number> = { architect: 0, implementer: 1, reviewer: 2, tester: 3 };
|
||||
const PROVIDER_ORDER: Record<string, number> = { anthropic: 0, openai: 1, moonshot: 2 };
|
||||
|
||||
function sortRoles(roles: string[]): string[] {
|
||||
return [...roles].sort((a, b) => {
|
||||
const oa = ROLE_ORDER[a] ?? 99;
|
||||
const ob = ROLE_ORDER[b] ?? 99;
|
||||
return oa - ob;
|
||||
});
|
||||
}
|
||||
|
||||
function gatherAgents(cwd: string): AgentView[] {
|
||||
/** Build the roster from config.agents, or fall back to the role config. */
|
||||
function gatherRoster(cwd: string): RosterAgent[] {
|
||||
const config = loadConfig(cwd);
|
||||
const byRole = new Map<string, string[]>();
|
||||
|
||||
for (const [role, cfg] of Object.entries(config.roles)) {
|
||||
const agents = byRole.get(role) ?? [];
|
||||
if (!agents.includes(cfg.preferredAgent)) {
|
||||
agents.push(cfg.preferredAgent);
|
||||
if (config.agents && Object.keys(config.agents).length > 0) {
|
||||
return Object.entries(config.agents).map(([name, a]) => ({
|
||||
name,
|
||||
role: a.role,
|
||||
model: a.model,
|
||||
kind: a.kind,
|
||||
description: a.description,
|
||||
}));
|
||||
}
|
||||
byRole.set(role, agents);
|
||||
}
|
||||
|
||||
// Also surface agents that currently have tasks assigned, even if not in config.
|
||||
const tasks = listTasks(cwd) as Array<{
|
||||
id: string;
|
||||
status: TaskStatus;
|
||||
assignedTo?: string;
|
||||
role?: string;
|
||||
}>;
|
||||
for (const t of tasks) {
|
||||
if (!t.assignedTo || !t.role) continue;
|
||||
const agents = byRole.get(t.role) ?? [];
|
||||
if (!agents.includes(t.assignedTo)) {
|
||||
agents.push(t.assignedTo);
|
||||
byRole.set(t.role, agents);
|
||||
}
|
||||
}
|
||||
|
||||
const agents: AgentView[] = [];
|
||||
for (const role of sortRoles(Array.from(byRole.keys()))) {
|
||||
const names = byRole.get(role) ?? [];
|
||||
for (const name of names.sort((a, b) => a.localeCompare(b))) {
|
||||
agents.push({ name, role, isArchitect: role === 'architect' });
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
// Fallback: no roster configured — derive one agent per role from config.roles.
|
||||
return Object.entries(config.roles).map(([role, cfg]) => ({ name: cfg.preferredAgent, role }));
|
||||
}
|
||||
|
||||
function attachBusyTasks(cwd: string, agents: AgentView[]): AgentView[] {
|
||||
const tasks = listTasks(cwd) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
assignedTo?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
|
||||
const busyByAgent = new Map<string, Task>();
|
||||
function attachBusy(cwd: string, roster: RosterAgent[]): RosterAgent[] {
|
||||
const tasks = listTasks(cwd) as TaskRow[];
|
||||
const busyByAgent = new Map<string, TaskRow>();
|
||||
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 as Task);
|
||||
if (!existing || t.updatedAt > existing.updatedAt) busyByAgent.set(t.assignedTo, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agents.map((a) => ({
|
||||
...a,
|
||||
busyTask: busyByAgent.get(a.name),
|
||||
}));
|
||||
return roster.map((a) => ({ ...a, busyTask: busyByAgent.get(a.name) }));
|
||||
}
|
||||
|
||||
function renderAgentCard(agent: AgentView): string {
|
||||
const busy = agent.busyTask;
|
||||
const statusDot = busy
|
||||
? `<span style="width:8px;height:8px;border-radius:50%;background:var(--status-in_progress);"></span> busy`
|
||||
: `<span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free`;
|
||||
|
||||
const busyLine = busy
|
||||
? `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
function statusLine(a: RosterAgent): string {
|
||||
const busy = a.busyTask;
|
||||
if (busy) {
|
||||
return `<div style="margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
|
||||
${statusPill('in_progress')}
|
||||
<a href="/tasks/${escapeHtml(busy.id)}" style="color:var(--accent);text-decoration:none;font-family:var(--font-mono);font-size:12px;" data-live-timer data-timer-at="${escapeHtml(busy.updatedAt)}" data-timer-mode="in_progress">claimed moments ago</a>
|
||||
</div>`
|
||||
: `<div style="margin-top:8px;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:12px;">
|
||||
${statusDot}
|
||||
<a href="/tasks/${escapeHtml(busy.id)}" style="color:var(--accent);text-decoration:none;font-family:var(--font-mono);font-size:12px;" data-live-timer data-timer-at="${escapeHtml(busy.updatedAt)}" data-timer-mode="in_progress">claimed</a>
|
||||
</div>`;
|
||||
}
|
||||
return `<div style="margin-top:10px;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:var(--green);"></span> free
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one agent card. `avatar:'logo'` shows the provider brand mark (for
|
||||
* standalone tiers); `avatar:'monogram'` shows the per-agent initials chip (used
|
||||
* inside a provider group whose header already carries the brand logo).
|
||||
*/
|
||||
function agentCard(a: RosterAgent, opts: { avatar: 'logo' | 'monogram'; architectRing?: boolean } = { avatar: 'monogram' }): string {
|
||||
const meta = providerMeta(a.kind);
|
||||
const avatar =
|
||||
opts.avatar === 'logo' && meta
|
||||
? `<span style="display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:9px;background:var(--raised);border:1px solid var(--border);${opts.architectRing ? `box-shadow:0 0 0 2px var(--bg),0 0 0 4px ${meta.color};` : ''}">${providerLogo(a.kind, 19)}</span>`
|
||||
: agentAvatar(a.name, { architectRing: opts.architectRing, size: 34 });
|
||||
|
||||
const model = a.model
|
||||
? `<span style="font-family:var(--font-mono);font-size:11px;color:${meta ? meta.color : 'var(--muted)'};">${escapeHtml(a.model)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="agent-card" style="
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
min-width: 220px;
|
||||
position: relative;
|
||||
">
|
||||
<div class="agent-card" style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 14px;min-width:212px;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
${agentAvatar(agent.name, { architectRing: agent.isArchitect, size: 32 })}
|
||||
${avatar}
|
||||
<div style="min-width:0;">
|
||||
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(agent.name)}</div>
|
||||
<div style="color:var(--muted);font-size:11px;text-transform:capitalize;">${escapeHtml(agent.role)}</div>
|
||||
<div style="font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(a.name)}</div>
|
||||
${model}
|
||||
</div>
|
||||
</div>
|
||||
${busyLine}
|
||||
${a.description ? `<div style="margin-top:8px;color:var(--muted);font-size:11px;line-height:1.4;">${escapeHtml(a.description)}</div>` : ''}
|
||||
${statusLine(a)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRoleGroup(role: string, agents: AgentView[]): string {
|
||||
const isArchitect = role === 'architect';
|
||||
/** A provider column: brand-logo header + the agents running on that provider. */
|
||||
function providerGroup(kind: string, agents: RosterAgent[]): string {
|
||||
const meta = providerMeta(kind);
|
||||
const busy = agents.filter((a) => a.busyTask).length;
|
||||
return `
|
||||
<div class="role-group" data-role="${escapeHtml(role)}" style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
">
|
||||
<div class="role-label" style="
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
z-index: 2;
|
||||
">${escapeHtml(role)}</div>
|
||||
<div class="agent-row" style="
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
position: relative;
|
||||
${isArchitect ? '' : 'padding-top: 18px; border-top: 1px solid var(--border); margin-top: -11px; width: 100%;'}
|
||||
">
|
||||
${agents.map(renderAgentCard).join('')}
|
||||
<div class="provider-group" style="background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:10px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding-bottom:4px;">
|
||||
${providerLogo(kind, 18)}
|
||||
<span style="font-weight:600;font-size:13px;">${meta ? escapeHtml(meta.name) : escapeHtml(kind)}</span>
|
||||
<span style="flex:1;"></span>
|
||||
<span style="color:var(--muted);font-size:11px;font-family:var(--font-mono);">${busy}/${agents.length} busy</span>
|
||||
</div>
|
||||
${agents.map((a) => agentCard(a, { avatar: 'monogram' })).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function tierLabel(text: string): string {
|
||||
return `<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted);background:var(--bg);padding:2px 12px;border:1px solid var(--border);border-radius:999px;">${escapeHtml(text)}</div>`;
|
||||
}
|
||||
|
||||
export function renderTeamHtml(cwd: string): string {
|
||||
const config = loadConfig(cwd);
|
||||
const agents = attachBusyTasks(cwd, gatherAgents(cwd));
|
||||
const roster = attachBusy(cwd, gatherRoster(cwd));
|
||||
|
||||
const byRole = new Map<string, AgentView[]>();
|
||||
for (const a of agents) {
|
||||
const byRole = new Map<string, RosterAgent[]>();
|
||||
for (const a of roster) {
|
||||
const list = byRole.get(a.role) ?? [];
|
||||
list.push(a);
|
||||
byRole.set(a.role, list);
|
||||
}
|
||||
|
||||
const orderedRoles = sortRoles(Array.from(byRole.keys()));
|
||||
const architectGroup = orderedRoles.includes('architect')
|
||||
? renderRoleGroup('architect', byRole.get('architect')!)
|
||||
const architects = (byRole.get('architect') ?? []).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const implementers = byRole.get('implementer') ?? [];
|
||||
const testers = (byRole.get('tester') ?? []).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Group implementers by provider, ordered anthropic -> openai -> moonshot -> rest.
|
||||
const byProvider = new Map<string, RosterAgent[]>();
|
||||
for (const a of implementers) {
|
||||
const key = a.kind ?? 'other';
|
||||
const list = byProvider.get(key) ?? [];
|
||||
list.push(a);
|
||||
byProvider.set(key, list);
|
||||
}
|
||||
const providerKeys = Array.from(byProvider.keys()).sort(
|
||||
(a, b) => (PROVIDER_ORDER[a] ?? 99) - (PROVIDER_ORDER[b] ?? 99) || a.localeCompare(b),
|
||||
);
|
||||
for (const k of byProvider.keys()) {
|
||||
byProvider.get(k)!.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
const architectTier = architects.length
|
||||
? `<div class="tier"><div class="tier-row">${architects.map((a) => agentCard(a, { avatar: 'logo', architectRing: true })).join('')}</div>${tierLabel('Architect')}</div>`
|
||||
: '';
|
||||
const workerGroups = orderedRoles
|
||||
.filter((r) => r !== 'architect')
|
||||
.map((r) => renderRoleGroup(r, byRole.get(r)!))
|
||||
.join('');
|
||||
|
||||
const implementerTier = providerKeys.length
|
||||
? `<div class="tier">${tierLabel('Implementers')}<div class="provider-row">${providerKeys.map((k) => providerGroup(k, byProvider.get(k)!)).join('')}</div></div>`
|
||||
: '';
|
||||
|
||||
const testerTier = testers.length
|
||||
? `<div class="tier">${tierLabel('Testers')}<div class="tier-row">${testers.map((a) => agentCard(a, { avatar: 'logo' })).join('')}</div></div>`
|
||||
: '';
|
||||
|
||||
const connector = '<div class="connector-down" aria-hidden="true"></div>';
|
||||
const tiers = [architectTier, implementerTier, testerTier].filter(Boolean).join(connector);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
@ -205,51 +185,27 @@ export function renderTeamHtml(cwd: string): string {
|
||||
<style>
|
||||
${designTokensCss()}
|
||||
|
||||
.tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 36px;
|
||||
padding: 8px 0 32px;
|
||||
}
|
||||
.tree { display:flex; flex-direction:column; align-items:center; gap:0; padding:8px 0 32px; }
|
||||
.tier { display:flex; flex-direction:column; align-items:center; gap:14px; width:100%; }
|
||||
.tier-row { display:flex; flex-wrap:wrap; justify-content:center; gap:14px; }
|
||||
.provider-row { display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:18px; width:100%; }
|
||||
.provider-group { min-width:240px; max-width:300px; }
|
||||
.connector-down { width:1px; height:28px; background:var(--border); flex:0 0 auto; }
|
||||
|
||||
.worker-tier {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Connector line from architect tier down to worker tier */
|
||||
.connector-down {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
transition: border-color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
.agent-card:hover {
|
||||
border-color: rgba(88, 166, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.agent-card { transition: border-color 150ms ease, transform 150ms ease; }
|
||||
.agent-card:hover { border-color: rgba(88,166,255,0.5); transform: translateY(-1px); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.worker-tier { flex-direction: column; align-items: center; gap: 24px; }
|
||||
.provider-row { flex-direction:column; align-items:center; gap:14px; }
|
||||
.provider-group { max-width:none; width:100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${pageHeader(config.projectName, 'team')}
|
||||
|
||||
<main class="tree" id="tree">
|
||||
${architectGroup}
|
||||
${workerGroups ? `<div class="connector-down" aria-hidden="true"></div><div class="worker-tier">${workerGroups}</div>` : ''}
|
||||
${tiers}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
${liveTimerJs()}
|
||||
</script>
|
||||
|
||||
@ -114,6 +114,42 @@ export function agentAvatar(
|
||||
">${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> = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user