huskies: merge 1046
This commit is contained in:
@@ -38,6 +38,7 @@ export interface ProjectPipelineStatus {
|
||||
active: PipelineItem[];
|
||||
backlog: { story_id: string; name: string }[];
|
||||
backlog_count: number;
|
||||
archived?: PipelineItem[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user