Files
huskies/frontend/src/components/GatewayPanel.tsx
T
2026-05-15 01:38:05 +00:00

1002 lines
25 KiB
TypeScript

/// Gateway management panel shown when huskies runs in `--gateway` mode.
///
/// Provides:
/// - A cross-project pipeline status view showing active stories per project.
/// - Clicking a project card switches to it.
/// - An "Add Agent" button that generates a one-time join token.
/// - Instructions for running a build agent with the token.
/// - A list of connected agents with per-agent status, project assignment, and "Remove" buttons.
/// - Auto-refresh every 5 seconds so new agents and disconnections appear without a page reload.
import * as React from "react";
import {
gatewayApi,
type JoinedAgent,
type GatewayProject,
type AllProjectsPipeline,
type Pipeline,
type PipelineItem,
type Status,
} from "../api/gateway";
/// Resolve an item's pipeline column. Servers running the new (story 1085)
/// backend send `pipeline`; older servers only send `stage` so we fall back to
/// mapping the bucket name onto the new column vocabulary.
function itemPipeline(item: PipelineItem): Pipeline {
if (item.pipeline) return item.pipeline;
switch (item.stage) {
case "current":
return "coding";
case "qa":
return "qa";
case "merge":
return "merge";
case "done":
return "done";
case "archived":
return "archived";
default:
return "backlog";
}
}
/// Resolve an item's badge. Falls back to `merge_failure`/`blocked` on
/// legacy servers that don't yet emit `status`.
function itemStatus(item: PipelineItem): Status {
if (item.status) return item.status;
if (item.merge_failure) return "merge-failure";
if (item.blocked) return "blocked";
if (item.stage === "done") return "done";
return "active";
}
const { useCallback, useEffect, useRef, useState } = React;
/// Seconds of silence before an agent is considered disconnected.
const DISCONNECT_THRESHOLD_SECS = 60;
/// Poll the agent list this often (milliseconds).
const POLL_INTERVAL_MS = 5_000;
type AgentStatus = "idle" | "working" | "disconnected";
/// Derive an agent's display status from its last-seen timestamp and project assignment.
function agentStatus(agent: JoinedAgent): AgentStatus {
const nowSecs = Date.now() / 1000;
if (nowSecs - agent.last_seen > DISCONNECT_THRESHOLD_SECS) {
return "disconnected";
}
return agent.assigned_project ? "working" : "idle";
}
const STATUS_COLORS: Record<AgentStatus, string> = {
idle: "#6e7681",
working: "#3fb950",
disconnected: "#f85149",
};
const STATUS_LABELS: Record<AgentStatus, string> = {
idle: "Idle",
working: "Working",
disconnected: "Disconnected",
};
const PIPELINE_COLORS: Record<Pipeline, string> = {
backlog: "#8b949e",
coding: "#3fb950",
qa: "#d2a679",
merge: "#79c0ff",
done: "#6e7681",
closed: "#6e7681",
archived: "#6e7681",
};
const PIPELINE_LABELS: Record<Pipeline, string> = {
backlog: "Backlog",
coding: "In Progress",
qa: "QA",
merge: "Merging",
done: "Done",
closed: "Closed",
archived: "Archived",
};
/// A single story row inside a project pipeline card.
/** Render one story row in a gateway-aggregate panel: `#<id> <name>` with status badge. */
export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) {
const pipeline = itemPipeline(item);
const status = itemStatus(item);
const agentStatus = item.agent?.status;
let color: string;
let label: string;
let frozenPrefix = "";
// Frozen items keep their underlying pipeline column but get a ❄️ badge.
// (AC 4 — story 1085, subsumes the freeze-hides-item bug.)
if (status === "frozen") {
color = "#79c0ff";
label = "❄ FROZEN";
frozenPrefix = "❄ ";
} else if (status === "merge-failure" || status === "merge-failure-final") {
// Done items never reach this branch — `Stage::status()` returns
// `Status::Done` for done items (AC 4).
if (agentStatus === "running") {
color = "#e3b341";
label = "⟳ RECOVERING";
} else if (agentStatus === "pending") {
color = "#e3b341";
label = "⏳ QUEUED";
} else {
color = "#f85149";
label = status === "merge-failure-final" ? "⛔ FAILED (FINAL)" : "✕ FAILED";
}
} else if (status === "blocked") {
if (agentStatus === "running") {
color = "#e3b341";
label = "⟳ RECOVERING";
} else if (agentStatus === "pending") {
color = "#e3b341";
label = "⏳ QUEUED";
} else {
color = "#f85149";
label = "⊘ BLOCKED";
}
} else if (status === "review-hold") {
color = "#d2a679";
label = "REVIEW HOLD";
} else if (status === "abandoned") {
color = "#6e7681";
label = "ABANDONED";
} else if (status === "superseded") {
color = "#6e7681";
label = "SUPERSEDED";
} else if (status === "rejected") {
color = "#f85149";
label = "REJECTED";
} else if (pipeline === "merge" && agentStatus === "running") {
color = "#58a6ff";
label = "▶ MERGING";
} else if (pipeline === "merge" && agentStatus === "pending") {
color = "#e3b341";
label = "⏳ QUEUED";
} else if (pipeline === "merge") {
color = "#6e7681";
if (mergeQueuePos === 1) {
label = "NEXT IN QUEUE";
} else if (mergeQueuePos != null) {
label = `awaiting-slot (#${mergeQueuePos})`;
} else {
label = "awaiting-slot";
}
} else {
color = PIPELINE_COLORS[pipeline] ?? "#8b949e";
label = PIPELINE_LABELS[pipeline] ?? pipeline;
}
const isMergeActive = pipeline === "merge" && status === "active" && agentStatus === "running";
const idNum = item.story_id.match(/^(\d+)/)?.[1];
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "4px 0",
fontSize: "0.82em",
background: isMergeActive ? "#58a6ff0a" : undefined,
borderRadius: isMergeActive ? "4px" : undefined,
paddingLeft: isMergeActive ? "4px" : undefined,
paddingRight: isMergeActive ? "4px" : undefined,
}}
>
<span
style={{
padding: "1px 6px",
borderRadius: "10px",
background: `${color}22`,
color,
border: `1px solid ${color}44`,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{label}
</span>
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
{frozenPrefix}{item.name}
</span>
</div>
);
}
function TokenDisplay({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
const envCmd = `HUSKIES_JOIN_TOKEN=${token} huskies agent --rendezvous <CRDT_SYNC_URL>`;
const flagCmd = `huskies agent --rendezvous <CRDT_SYNC_URL> --join-token ${token}`;
const copyToClipboard = useCallback((text: string) => {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, []);
return (
<div
style={{
marginTop: "12px",
padding: "12px 16px",
background: "#161b22",
border: "1px solid #238636",
borderRadius: "8px",
fontSize: "0.85em",
}}
>
<div style={{ color: "#3fb950", fontWeight: 600, marginBottom: "8px" }}>
Token generated run the build agent with one of:
</div>
<div style={{ marginBottom: "6px" }}>
<code
style={{
display: "block",
background: "#0d1117",
padding: "8px 10px",
borderRadius: "4px",
color: "#e6edf3",
wordBreak: "break-all",
}}
>
{envCmd}
</code>
</div>
<div>
<code
style={{
display: "block",
background: "#0d1117",
padding: "8px 10px",
borderRadius: "4px",
color: "#e6edf3",
wordBreak: "break-all",
}}
>
{flagCmd}
</code>
</div>
<button
type="button"
onClick={() => copyToClipboard(flagCmd)}
style={{
marginTop: "8px",
fontSize: "0.8em",
padding: "3px 10px",
borderRadius: "4px",
border: "1px solid #444",
background: "none",
color: "#aaa",
cursor: "pointer",
}}
>
{copied ? "Copied!" : "Copy flag command"}
</button>
<div style={{ marginTop: "8px", color: "#666", fontSize: "0.85em" }}>
This token is single-use. Generate a new one for each agent.
</div>
</div>
);
}
function AgentRow({
agent,
projects,
onRemove,
onAssign,
}: {
agent: JoinedAgent;
projects: GatewayProject[];
onRemove: (id: string) => void;
onAssign: (id: string, project: string | null) => void;
}) {
const registeredAt = new Date(agent.registered_at * 1000).toLocaleString();
const status = agentStatus(agent);
const statusColor = STATUS_COLORS[status];
const statusLabel = STATUS_LABELS[status];
return (
<div
data-testid={`agent-row-${agent.id}`}
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 14px",
background: "#161b22",
border: "1px solid #30363d",
borderRadius: "8px",
marginBottom: "8px",
}}
>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
background: statusColor,
flexShrink: 0,
}}
title={statusLabel}
/>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{agent.label}</span>
<span
data-testid={`agent-status-${agent.id}`}
style={{
fontSize: "0.75em",
padding: "1px 6px",
borderRadius: "10px",
background: `${statusColor}22`,
color: statusColor,
border: `1px solid ${statusColor}44`,
}}
>
{statusLabel}
</span>
</div>
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>
{agent.address}
</div>
<div style={{ fontSize: "0.75em", color: "#6e7681" }}>
Registered {registeredAt}
{agent.assigned_project && (
<span style={{ marginLeft: "8px", color: "#8b949e" }}>
· Project: {agent.assigned_project}
</span>
)}
</div>
</div>
<select
data-testid={`assign-agent-${agent.id}`}
value={agent.assigned_project ?? ""}
onChange={(e) =>
onAssign(agent.id, e.target.value === "" ? null : e.target.value)
}
style={{
fontSize: "0.8em",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #30363d",
background: "#0d1117",
color: "#e6edf3",
cursor: "pointer",
}}
>
<option value=""> unassigned </option>
{projects.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
<button
type="button"
data-testid={`remove-agent-${agent.id}`}
onClick={() => onRemove(agent.id)}
style={{
fontSize: "0.8em",
padding: "4px 10px",
borderRadius: "4px",
border: "1px solid #f85149",
background: "none",
color: "#f85149",
cursor: "pointer",
}}
>
Remove
</button>
</div>
);
}
type TabKey = "backlog" | "in-progress" | "done" | "archived";
const TAB_STORAGE_KEY = "gateway_selected_tab";
/// Read the persisted tab from localStorage, defaulting to "in-progress".
function readStoredTab(): TabKey {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (
stored === "backlog" ||
stored === "in-progress" ||
stored === "done" ||
stored === "archived"
) {
return stored;
}
return "in-progress";
}
/// Aggregate pipeline items from all projects for a given tab.
function aggregateItems(
pipeline: AllProjectsPipeline,
tab: TabKey,
): { project: string; items: PipelineItem[] }[] {
return Object.entries(pipeline.projects)
.map(([project, status]) => {
if (status.error) return { project, items: [] };
if (tab === "backlog") {
return {
project,
items: (status.backlog ?? []).map((b) => ({
story_id: b.story_id,
name: b.name,
stage: "backlog",
pipeline: "backlog" as Pipeline,
status: "active" as Status,
})),
};
}
if (tab === "in-progress") {
return {
project,
items: (status.active ?? []).filter(
(i) => itemPipeline(i) !== "done",
),
};
}
if (tab === "done") {
return {
project,
items: (status.active ?? []).filter((i) => itemPipeline(i) === "done"),
};
}
// archived
return { project, items: status.archived ?? [] };
})
.filter((g) => g.items.length > 0);
}
/// Count total items across all projects for a given tab.
function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number {
return Object.values(pipeline.projects).reduce((sum, status) => {
if (status.error) return sum;
if (tab === "backlog") return sum + (status.backlog_count ?? 0);
if (tab === "in-progress") {
return (
sum +
(status.active ?? []).filter((i) => itemPipeline(i) !== "done").length
);
}
if (tab === "done") {
return (
sum + (status.active ?? []).filter((i) => itemPipeline(i) === "done").length
);
}
return sum + (status.archived ?? []).length;
}, 0);
}
/// Tab bar button.
function TabButton({
label,
count,
active,
onClick,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "8px 16px",
borderRadius: "6px 6px 0 0",
border: "1px solid",
borderColor: active ? "#30363d" : "transparent",
borderBottomColor: active ? "#0d1117" : "transparent",
background: active ? "#0d1117" : "none",
color: active ? "#e6edf3" : "#8b949e",
cursor: "pointer",
fontSize: "0.9em",
fontWeight: active ? 600 : 400,
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
{label}
{count > 0 && (
<span
style={{
padding: "1px 6px",
borderRadius: "10px",
background: active ? "#21262d" : "#161b22",
color: active ? "#e6edf3" : "#6e7681",
fontSize: "0.8em",
}}
>
{count}
</span>
)}
</button>
);
}
/// A project-labelled story row used in the aggregate tab view.
function ProjectStoryRow({
project,
item,
showProject,
mergeQueuePos,
}: {
project: string;
item: PipelineItem;
showProject: boolean;
mergeQueuePos?: number;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{showProject && (
<span
style={{
fontSize: "0.75em",
padding: "1px 6px",
borderRadius: "10px",
background: "#161b22",
color: "#8b949e",
border: "1px solid #30363d",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{project}
</span>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<StoryRow item={item} mergeQueuePos={mergeQueuePos} />
</div>
</div>
);
}
const IN_PROGRESS_PIPELINE_LABELS: Record<"coding" | "qa" | "merge", string> = {
coding: "Coding",
qa: "QA",
merge: "Merging",
};
/// In Progress tab content — items grouped by their `pipeline` column.
///
/// Frozen items appear in the column corresponding to their underlying
/// `Stage::resume_to` (server-side), so they always show up in-place.
function InProgressTabContent({
groups,
}: {
groups: { project: string; items: PipelineItem[] }[];
}) {
const allItems = groups.flatMap((g) =>
g.items.map((item) => ({ project: g.project, item })),
);
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
const byPipeline = {
coding: allItems.filter((x) => itemPipeline(x.item) === "coding"),
qa: allItems.filter((x) => itemPipeline(x.item) === "qa"),
merge: allItems.filter((x) => itemPipeline(x.item) === "merge"),
};
const pipelines = (["coding", "qa", "merge"] as const).filter(
(p) => byPipeline[p].length > 0,
);
// Compute queue position among "clean" awaiting-merge items: pipeline=merge,
// status=active, and no agent currently running.
const mergeQueuePosMap = new Map<string, number>();
let queuePos = 0;
for (const { project, item } of byPipeline.merge) {
if (itemStatus(item) === "active" && item.agent?.status !== "running") {
queuePos += 1;
mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos);
}
}
if (allItems.length === 0) {
return (
<p style={{ color: "#6e7681", padding: "16px 0" }}>
No items in progress.
</p>
);
}
return (
<div>
{pipelines.map((p) => (
<div key={p} style={{ marginBottom: "20px" }}>
<div
style={{
fontSize: "0.8em",
fontWeight: 600,
color: PIPELINE_COLORS[p] ?? "#8b949e",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "8px",
paddingBottom: "4px",
borderBottom: `1px solid ${PIPELINE_COLORS[p] ?? "#8b949e"}33`,
}}
>
{IN_PROGRESS_PIPELINE_LABELS[p]}{" "}
<span style={{ color: "#6e7681" }}>
({byPipeline[p].length})
</span>
</div>
{byPipeline[p].map(({ project, item }) => (
<ProjectStoryRow
key={`${project}:${item.story_id}`}
project={project}
item={item}
showProject={multiProject}
mergeQueuePos={
p === "merge"
? mergeQueuePosMap.get(`${project}:${item.story_id}`)
: undefined
}
/>
))}
</div>
))}
</div>
);
}
/// Flat list tab content for Backlog, Done, and Archived.
function FlatTabContent({
groups,
emptyMessage,
}: {
groups: { project: string; items: PipelineItem[] }[];
emptyMessage: string;
}) {
const allItems = groups.flatMap((g) =>
g.items.map((item) => ({ project: g.project, item })),
);
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
if (allItems.length === 0) {
return (
<p style={{ color: "#6e7681", padding: "16px 0" }}>{emptyMessage}</p>
);
}
return (
<div>
{allItems.map(({ project, item }) => (
<ProjectStoryRow
key={`${project}:${item.story_id}`}
project={project}
item={item}
showProject={multiProject}
/>
))}
</div>
);
}
/// Gateway management panel — rendered when running in `--gateway` mode.
export function GatewayPanel() {
const [agents, setAgents] = useState<JoinedAgent[]>([]);
const [projects, setProjects] = useState<GatewayProject[]>([]);
const [token, setToken] = useState<string | null>(null);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pipeline, setPipeline] = useState<AllProjectsPipeline | null>(null);
const [selectedTab, setSelectedTab] = useState<TabKey>(readStoredTab);
// Keep stable refs so polling intervals don't recreate on state changes.
const setAgentsRef = useRef(setAgents);
setAgentsRef.current = setAgents;
const setPipelineRef = useRef(setPipeline);
setPipelineRef.current = setPipeline;
useEffect(() => {
// Initial load.
gatewayApi
.listAgents()
.then(setAgents)
.catch(() => setAgents([]));
gatewayApi
.getGatewayInfo()
.then((info) => setProjects(info.projects))
.catch(() => setProjects([]));
gatewayApi
.getAllProjectsPipeline()
.then(setPipeline)
.catch(() => setPipeline(null));
// Poll so the dashboard auto-updates as agents connect/disconnect and
// stories move through pipelines.
const timer = setInterval(() => {
gatewayApi
.listAgents()
.then((updated) => setAgentsRef.current(updated))
.catch(() => {});
gatewayApi
.getAllProjectsPipeline()
.then((updated) => setPipelineRef.current(updated))
.catch(() => {});
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, []);
const handleAddAgent = useCallback(async () => {
setGenerating(true);
setError(null);
setToken(null);
try {
const result = await gatewayApi.generateToken();
setToken(result.token);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setGenerating(false);
}
}, []);
const handleRemoveAgent = useCallback(async (id: string) => {
try {
await gatewayApi.removeAgent(id);
setAgents((prev) => prev.filter((a) => a.id !== id));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
const handleAssignAgent = useCallback(
async (id: string, project: string | null) => {
try {
const updated = await gatewayApi.assignAgent(id, project);
setAgents((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a)),
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
},
[],
);
const handleSelectTab = useCallback((tab: TabKey) => {
setSelectedTab(tab);
localStorage.setItem(TAB_STORAGE_KEY, tab);
}, []);
return (
<div
style={{
minHeight: "100vh",
background: "#0d1117",
color: "#e6edf3",
padding: "32px",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
<div style={{ maxWidth: "720px", margin: "0 auto" }}>
<h1 style={{ fontSize: "1.5em", fontWeight: 700, marginBottom: "4px" }}>
Huskies Gateway
</h1>
<p style={{ color: "#8b949e", marginBottom: "32px" }}>
Manage build agents connected to this gateway.
</p>
{/* Cross-project pipeline tabs */}
<section style={{ marginBottom: "32px" }}>
{/* Tab bar */}
<div
style={{
display: "flex",
gap: "2px",
borderBottom: "1px solid #30363d",
marginBottom: "16px",
}}
>
{(
[
{ key: "backlog", label: "Backlog" },
{ key: "in-progress", label: "In Progress" },
{ key: "done", label: "Done" },
{ key: "archived", label: "Archived" },
] as { key: TabKey; label: string }[]
).map(({ key, label }) => (
<TabButton
key={key}
label={label}
count={pipeline ? tabCount(pipeline, key) : 0}
active={selectedTab === key}
onClick={() => handleSelectTab(key)}
/>
))}
</div>
{/* Tab content */}
{pipeline ? (
<>
{selectedTab === "backlog" && (
<FlatTabContent
groups={aggregateItems(pipeline, "backlog")}
emptyMessage="No items in backlog."
/>
)}
{selectedTab === "in-progress" && (
<InProgressTabContent
groups={aggregateItems(pipeline, "in-progress")}
/>
)}
{selectedTab === "done" && (
<FlatTabContent
groups={aggregateItems(pipeline, "done")}
emptyMessage="No completed items."
/>
)}
{selectedTab === "archived" && (
<FlatTabContent
groups={aggregateItems(pipeline, "archived")}
emptyMessage="No archived items."
/>
)}
</>
) : (
<p style={{ color: "#6e7681" }}>Loading pipeline status</p>
)}
</section>
{/* Add Agent */}
<section style={{ marginBottom: "32px" }}>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Add Agent
</h2>
<button
type="button"
data-testid="add-agent-button"
onClick={handleAddAgent}
disabled={generating}
style={{
padding: "8px 18px",
borderRadius: "6px",
border: "1px solid #238636",
background: generating ? "#1a2f1a" : "#238636",
color: "#fff",
cursor: generating ? "not-allowed" : "pointer",
fontWeight: 600,
fontSize: "0.9em",
}}
>
{generating ? "Generating…" : "Add Agent"}
</button>
{token && <TokenDisplay token={token} />}
</section>
{/* Agent list */}
<section>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Connected Agents{" "}
{agents.length > 0 && (
<span
style={{
fontSize: "0.8em",
color: "#8b949e",
fontWeight: 400,
}}
>
({agents.length})
</span>
)}
</h2>
{agents.length === 0 ? (
<p style={{ color: "#6e7681" }}>
No agents connected yet. Click "Add Agent" to generate a join
token.
</p>
) : (
<div>
{agents.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
projects={projects}
onRemove={handleRemoveAgent}
onAssign={handleAssignAgent}
/>
))}
</div>
)}
</section>
{/* Project management */}
<section style={{ marginTop: "32px" }}>
<h2
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
}}
>
Projects{" "}
{projects.length > 0 && (
<span style={{ fontSize: "0.8em", color: "#8b949e", fontWeight: 400 }}>
({projects.length})
</span>
)}
</h2>
{/* Existing projects list */}
{projects.map((p) => (
<div
key={p.name}
data-testid={`project-row-${p.name}`}
style={{
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 14px",
background: "#161b22",
border: "1px solid #30363d",
borderRadius: "8px",
marginBottom: "8px",
}}
>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#e6edf3" }}>{p.name}</div>
<div style={{ fontSize: "0.8em", color: "#8b949e" }}>{p.url}</div>
</div>
</div>
))}
</section>
{error && (
<div
style={{
marginTop: "16px",
padding: "10px 14px",
background: "#f8514911",
border: "1px solid #f85149",
borderRadius: "6px",
color: "#f85149",
fontSize: "0.875em",
}}
>
{error}
</div>
)}
</div>
</div>
);
}