huskies: merge 1046

This commit is contained in:
dave
2026-05-14 16:13:01 +00:00
parent 8f6ba69bf2
commit 9e06fff8a8
6 changed files with 342 additions and 134 deletions
+1
View File
@@ -38,6 +38,7 @@ export interface ProjectPipelineStatus {
active: PipelineItem[];
backlog: { story_id: string; name: string }[];
backlog_count: number;
archived?: PipelineItem[];
error?: string;
}
+323 -132
View File
@@ -49,17 +49,21 @@ const STATUS_LABELS: Record<AgentStatus, string> = {
};
const STAGE_COLORS: Record<string, string> = {
backlog: "#8b949e",
current: "#3fb950",
qa: "#d2a679",
merge: "#79c0ff",
done: "#6e7681",
archived: "#6e7681",
};
const STAGE_LABELS: Record<string, string> = {
backlog: "Backlog",
current: "In Progress",
qa: "QA",
merge: "Merging",
done: "Done",
archived: "Archived",
};
/// A single story row inside a project pipeline card.
@@ -120,106 +124,6 @@ export function StoryRow({ item }: { item: PipelineItem }) {
);
}
/// Pipeline status card for a single project.
function ProjectPipelineCard({
name,
pipeline,
isActive,
onSwitch,
}: {
name: string;
pipeline: AllProjectsPipeline["projects"][string];
isActive: boolean;
onSwitch: (name: string) => void;
}) {
const activeItems = pipeline.active ?? [];
const backlogItems = pipeline.backlog ?? [];
const backlogCount = pipeline.backlog_count ?? 0;
const remainingBacklog = backlogCount - backlogItems.length;
const hasError = Boolean(pipeline.error);
return (
<div
data-testid={`pipeline-card-${name}`}
onClick={() => onSwitch(name)}
style={{
padding: "12px 16px",
background: "#161b22",
border: `1px solid ${isActive ? "#238636" : "#30363d"}`,
borderRadius: "8px",
marginBottom: "8px",
cursor: "pointer",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: activeItems.length > 0 || backlogItems.length > 0 ? "8px" : 0,
}}
>
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{name}</span>
{isActive && (
<span
style={{
fontSize: "0.7em",
padding: "1px 6px",
borderRadius: "10px",
background: "#23863622",
color: "#3fb950",
border: "1px solid #23863644",
}}
>
active
</span>
)}
</div>
{hasError ? (
<div style={{ fontSize: "0.8em", color: "#f85149" }}>{pipeline.error}</div>
) : activeItems.length === 0 && backlogItems.length === 0 ? (
<div style={{ fontSize: "0.8em", color: "#6e7681" }}>
{backlogCount > 0 ? `${backlogCount} in backlog` : "No active stories"}
</div>
) : (
<div>
{activeItems.map((item) => (
<StoryRow key={item.story_id} item={item} />
))}
{backlogItems.length > 0 && (
<div style={{ marginTop: activeItems.length > 0 ? "6px" : 0, borderTop: activeItems.length > 0 ? "1px solid #21262d" : "none", paddingTop: activeItems.length > 0 ? "6px" : 0 }}>
{backlogItems.map((item) => {
const idNum = item.story_id.match(/^(\d+)/)?.[1];
return (
<div
key={item.story_id}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "2px 0",
fontSize: "0.82em",
color: "#6e7681",
}}
>
{idNum && <span style={{ fontFamily: "monospace", flexShrink: 0 }}>#{idNum}</span>}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.name}</span>
</div>
);
})}
{remainingBacklog > 0 && (
<div style={{ fontSize: "0.75em", color: "#6e7681", paddingTop: "2px" }}>
and {remainingBacklog} more
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
function TokenDisplay({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
@@ -411,6 +315,270 @@ function AgentRow({
);
}
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",
})),
};
}
if (tab === "in-progress") {
return {
project,
items: (status.active ?? []).filter(
(i) => i.stage !== "done",
),
};
}
if (tab === "done") {
return {
project,
items: (status.active ?? []).filter((i) => i.stage === "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) => i.stage !== "done").length
);
}
if (tab === "done") {
return (
sum + (status.active ?? []).filter((i) => i.stage === "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,
}: {
project: string;
item: PipelineItem;
showProject: boolean;
}) {
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} />
</div>
</div>
);
}
const IN_PROGRESS_STAGE_LABELS: Record<string, string> = {
current: "Coding",
qa: "QA",
merge: "Merging",
};
/// In Progress tab content — items grouped by stage (coding / qa / merging).
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 byStage = {
current: allItems.filter((x) => x.item.stage === "current"),
qa: allItems.filter((x) => x.item.stage === "qa"),
merge: allItems.filter((x) => x.item.stage === "merge"),
};
const stages = (["current", "qa", "merge"] as const).filter(
(s) => byStage[s].length > 0,
);
if (allItems.length === 0) {
return (
<p style={{ color: "#6e7681", padding: "16px 0" }}>
No items in progress.
</p>
);
}
return (
<div>
{stages.map((stage) => (
<div key={stage} style={{ marginBottom: "20px" }}>
<div
style={{
fontSize: "0.8em",
fontWeight: 600,
color: STAGE_COLORS[stage] ?? "#8b949e",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "8px",
paddingBottom: "4px",
borderBottom: `1px solid ${STAGE_COLORS[stage] ?? "#8b949e"}33`,
}}
>
{IN_PROGRESS_STAGE_LABELS[stage]}{" "}
<span style={{ color: "#6e7681" }}>
({byStage[stage].length})
</span>
</div>
{byStage[stage].map(({ project, item }) => (
<ProjectStoryRow
key={`${project}:${item.story_id}`}
project={project}
item={item}
showProject={multiProject}
/>
))}
</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[]>([]);
@@ -419,6 +587,7 @@ export function GatewayPanel() {
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);
@@ -494,20 +663,9 @@ export function GatewayPanel() {
[],
);
const handleSwitchProject = useCallback(async (name: string) => {
setError(null);
try {
const result = await gatewayApi.switchProject(name);
if (!result.ok) {
setError(result.error ?? "Failed to switch project");
return;
}
// Refresh pipeline to reflect new active project.
const updated = await gatewayApi.getAllProjectsPipeline();
setPipeline(updated);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
const handleSelectTab = useCallback((tab: TabKey) => {
setSelectedTab(tab);
localStorage.setItem(TAB_STORAGE_KEY, tab);
}, []);
@@ -529,29 +687,62 @@ export function GatewayPanel() {
Manage build agents connected to this gateway.
</p>
{/* Cross-project pipeline status */}
{/* Cross-project pipeline tabs */}
<section style={{ marginBottom: "32px" }}>
<h2
{/* Tab bar */}
<div
style={{
fontSize: "1.1em",
fontWeight: 600,
marginBottom: "12px",
borderBottom: "1px solid #21262d",
paddingBottom: "8px",
display: "flex",
gap: "2px",
borderBottom: "1px solid #30363d",
marginBottom: "16px",
}}
>
Pipeline Status
</h2>
{pipeline ? (
Object.entries(pipeline.projects).map(([name, status]) => (
<ProjectPipelineCard
key={name}
name={name}
pipeline={status}
isActive={name === pipeline.active}
onSwitch={handleSwitchProject}
{(
[
{ 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>
)}