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>
)}
@@ -90,10 +90,17 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
})
.collect();
let archived: Vec<Value> = state
.archived
.iter()
.map(|s| json!({ "story_id": s.story_id, "name": s.name, "stage": "archived" }))
.collect();
serde_json::to_string_pretty(&json!({
"active": active,
"backlog": backlog,
"backlog_count": backlog.len(),
"archived": archived,
"deterministic_merges_in_flight": running_merges,
}))
.map_err(|e| format!("Serialization error: {e}"))
+5 -1
View File
@@ -64,6 +64,8 @@ pub struct PipelineState {
pub done: Vec<UpcomingStory>,
/// Abandoned, superseded, and rejected items (story 984).
pub closed: Vec<UpcomingStory>,
/// Items swept from Done into the archived terminal state.
pub archived: Vec<UpcomingStory>,
/// Story IDs that currently have a deterministic merge in progress.
pub deterministic_merges_in_flight: Vec<String>,
}
@@ -104,6 +106,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
merge: Vec::new(),
done: Vec::new(),
closed: Vec::new(),
archived: Vec::new(),
deterministic_merges_in_flight,
};
@@ -194,7 +197,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
state.closed.push(story)
}
Stage::Archived { .. } => {} // Completed/MergeFailed/ReviewHeld stay hidden
Stage::Archived { .. } => state.archived.push(story),
}
}
@@ -205,6 +208,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
state.merge.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.done.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.closed.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.archived.sort_by(|a, b| a.story_id.cmp(&b.story_id));
Ok(state)
}
+3 -1
View File
@@ -234,11 +234,13 @@ pub async fn fetch_one_project_pipeline_items(url: &str, client: &Client) -> Val
match serde_json::from_str::<Value>(text) {
Ok(pipeline) => {
let active = pipeline.get("active").cloned().unwrap_or(json!([]));
let backlog = pipeline.get("backlog").cloned().unwrap_or(json!([]));
let backlog_count = pipeline
.get("backlog_count")
.and_then(|n| n.as_u64())
.unwrap_or(0);
json!({ "active": active, "backlog_count": backlog_count })
let archived = pipeline.get("archived").cloned().unwrap_or(json!([]));
json!({ "active": active, "backlog": backlog, "backlog_count": backlog_count, "archived": archived })
}
Err(_) => json!({ "error": "invalid pipeline JSON" }),
}
+3
View File
@@ -249,6 +249,7 @@ mod tests {
epic_id: None,
}],
closed: vec![],
archived: vec![],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
@@ -273,6 +274,7 @@ mod tests {
merge: vec![],
done: vec![],
closed: vec![],
archived: vec![],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
@@ -311,6 +313,7 @@ mod tests {
merge: vec![],
done: vec![],
closed: vec![],
archived: vec![],
deterministic_merges_in_flight: vec![],
};
let resp: WsResponse = state.into();