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>
|
||||
)}
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user