fix(HAI-009): move queued tasks to Up Next group and add tests
- Fix worktreeGrouping to place queued tasks in Up Next group only - Add unit tests for worktreeGrouping utility (120+ lines) - Clean up InlineCreateCard component styling - Remove unused code from triage engine - Fix TaskCard status display
This commit is contained in:
120
packages/dashboard/app/utils/worktreeGrouping.test.ts
Normal file
120
packages/dashboard/app/utils/worktreeGrouping.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { groupByWorktree, getWorktreeLabel } from "./worktreeGrouping";
|
||||
import type { Task } from "@hai/core";
|
||||
|
||||
function makeTask(overrides: Partial<Task> & { id: string }): Task {
|
||||
return {
|
||||
description: "",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getWorktreeLabel", () => {
|
||||
it("extracts last path segment", () => {
|
||||
expect(getWorktreeLabel(".worktrees/HAI-001")).toBe("HAI-001");
|
||||
expect(getWorktreeLabel("/path/to/hai/hai-001")).toBe("hai-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByWorktree", () => {
|
||||
it("groups active in-progress tasks by worktree", () => {
|
||||
const t1 = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
|
||||
const t2 = makeTask({ id: "HAI-002", worktree: ".worktrees/HAI-002" });
|
||||
|
||||
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].label).toBe("HAI-001");
|
||||
expect(groups[0].activeTasks).toEqual([t1]);
|
||||
expect(groups[1].label).toBe("HAI-002");
|
||||
expect(groups[1].activeTasks).toEqual([t2]);
|
||||
});
|
||||
|
||||
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
|
||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
|
||||
const queued = makeTask({
|
||||
id: "HAI-002",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
});
|
||||
|
||||
const groups = groupByWorktree([active], [active, queued], 2);
|
||||
|
||||
// Worktree group should have no queued tasks
|
||||
const worktreeGroup = groups.find((g) => g.label === "HAI-001");
|
||||
expect(worktreeGroup).toBeDefined();
|
||||
expect(worktreeGroup!.queuedTasks).toEqual([]);
|
||||
|
||||
// Up Next should contain the queued task
|
||||
const upNext = groups.find((g) => g.label === "Up Next");
|
||||
expect(upNext).toBeDefined();
|
||||
expect(upNext!.queuedTasks).toEqual([queued]);
|
||||
expect(upNext!.activeTasks).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not create Up Next group when there are no eligible queued tasks", () => {
|
||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
|
||||
|
||||
const groups = groupByWorktree([active], [active], 2);
|
||||
|
||||
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
|
||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
|
||||
const blocked = makeTask({
|
||||
id: "HAI-002",
|
||||
column: "todo",
|
||||
dependencies: ["HAI-003"], // HAI-003 doesn't exist or isn't done
|
||||
});
|
||||
|
||||
const groups = groupByWorktree([active], [active, blocked], 2);
|
||||
|
||||
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects maxConcurrent limit on queued tasks shown", () => {
|
||||
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
|
||||
const q1 = makeTask({ id: "HAI-010", column: "todo" });
|
||||
const q2 = makeTask({ id: "HAI-011", column: "todo" });
|
||||
const q3 = makeTask({ id: "HAI-012", column: "todo" });
|
||||
|
||||
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
|
||||
|
||||
const upNext = groups.find((g) => g.label === "Up Next");
|
||||
expect(upNext).toBeDefined();
|
||||
expect(upNext!.queuedTasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("places unassigned in-progress tasks in Unassigned group", () => {
|
||||
const unassigned = makeTask({ id: "HAI-001" }); // no worktree
|
||||
|
||||
const groups = groupByWorktree([unassigned], [unassigned], 2);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].label).toBe("Unassigned");
|
||||
expect(groups[0].activeTasks).toEqual([unassigned]);
|
||||
});
|
||||
|
||||
it("queued tasks with satisfied deps appear in Up Next", () => {
|
||||
const done = makeTask({ id: "HAI-001", column: "done" });
|
||||
const queued = makeTask({
|
||||
id: "HAI-002",
|
||||
column: "todo",
|
||||
dependencies: ["HAI-001"],
|
||||
});
|
||||
|
||||
const groups = groupByWorktree([], [done, queued], 2);
|
||||
|
||||
const upNext = groups.find((g) => g.label === "Up Next");
|
||||
expect(upNext).toBeDefined();
|
||||
expect(upNext!.queuedTasks).toEqual([queued]);
|
||||
});
|
||||
});
|
||||
@@ -47,8 +47,13 @@ function resolveDependencyOrder(tasks: Task[]): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Group in-progress tasks by worktree and distribute queued todo tasks
|
||||
* as visual previews across the worktree groups.
|
||||
* Group in-progress tasks by worktree and collect queued todo tasks
|
||||
* as visual previews in the "Up Next" group.
|
||||
*
|
||||
* Queued tasks (eligible "todo" tasks whose dependencies are all satisfied)
|
||||
* are always placed in the "Up Next" group — they are never distributed
|
||||
* to worktree-specific groups since they have no worktree assignment yet.
|
||||
* The number of queued tasks shown is capped at `maxConcurrent`.
|
||||
*/
|
||||
export function groupByWorktree(
|
||||
inProgressTasks: Task[],
|
||||
@@ -105,26 +110,14 @@ export function groupByWorktree(
|
||||
});
|
||||
}
|
||||
|
||||
// Distribute queued tasks round-robin across active worktree groups (one per group)
|
||||
const activeGroups = groups.filter((g) => g.label !== "Unassigned");
|
||||
let queueIdx = 0;
|
||||
|
||||
if (activeGroups.length > 0 && orderedEligible.length > 0) {
|
||||
for (let i = 0; i < activeGroups.length && queueIdx < orderedEligible.length; i++) {
|
||||
activeGroups[i].queuedTasks.push(orderedEligible[queueIdx++]);
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining queued tasks go into "Up Next" overflow group
|
||||
if (queueIdx < orderedEligible.length) {
|
||||
const remaining = orderedEligible.slice(queueIdx, queueIdx + maxConcurrent);
|
||||
if (remaining.length > 0) {
|
||||
groups.push({
|
||||
label: "Up Next",
|
||||
activeTasks: [],
|
||||
queuedTasks: remaining,
|
||||
});
|
||||
}
|
||||
// All eligible queued tasks go into the "Up Next" group (capped at maxConcurrent)
|
||||
const queued = orderedEligible.slice(0, maxConcurrent);
|
||||
if (queued.length > 0) {
|
||||
groups.push({
|
||||
label: "Up Next",
|
||||
activeTasks: [],
|
||||
queuedTasks: queued,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
|
||||
Reference in New Issue
Block a user