diff --git a/apps/web/src/__tests__/tasks-board-swimlanes.test.ts b/apps/web/src/__tests__/tasks-board-swimlanes.test.ts new file mode 100644 index 0000000..a01a33a --- /dev/null +++ b/apps/web/src/__tests__/tasks-board-swimlanes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { FilterableCard } from "../lib/tasks-board-filters"; +import { + buildSwimlanes, + cardsInSwimlane, + primaryAssigneeLogin, + swimlaneIdForCard, +} from "../lib/tasks-board-swimlanes"; + +function card(partial: Partial & { id: string }): FilterableCard { + return { + column_id: "ready", + title: "Task", + description: null, + tags_json: null, + due_at: null, + priority: 2, + sort_order: 0, + parent_card_id: null, + context_json: null, + ...partial, + }; +} + +describe("tasks-board-swimlanes", () => { + it("builds a single lane when group-by is none", () => { + const lanes = buildSwimlanes([card({ id: "a" })], "none"); + expect(lanes).toHaveLength(1); + expect(lanes[0]!.id).toBe("none"); + }); + + it("groups by priority into P0–P3 lanes", () => { + const cards = [ + card({ id: "a", priority: 0 }), + card({ id: "b", priority: 2 }), + ]; + const lanes = buildSwimlanes(cards, "priority"); + expect(lanes.map((l) => l.id)).toEqual([ + "priority:0", + "priority:1", + "priority:2", + "priority:3", + ]); + expect(cardsInSwimlane(cards, lanes[0]!, "priority").map((c) => c.id)).toEqual([ + "a", + ]); + expect(swimlaneIdForCard(cards[1]!, "priority")).toBe("priority:2"); + }); + + it("groups by primary assignee with Unassigned lane", () => { + const cards = [ + card({ + id: "a", + context_json: JSON.stringify({ + github: { assignees: [{ login: "alice" }] }, + }), + }), + card({ id: "b", context_json: null }), + ]; + expect(primaryAssigneeLogin(cards[0]!)).toBe("alice"); + const lanes = buildSwimlanes(cards, "assignee"); + expect(lanes.map((l) => l.label)).toEqual(["alice", "Unassigned"]); + expect( + cardsInSwimlane(cards, lanes[1]!, "assignee").map((c) => c.id) + ).toEqual(["b"]); + }); +}); diff --git a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx index c42cc1d..6dc0234 100644 --- a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx +++ b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx @@ -116,6 +116,14 @@ import { type BoardFilterState, type BoardSortKey, } from "@/lib/tasks-board-filters"; +import { + buildSwimlanes, + cardsInSwimlane, + loadSwimlaneGroupBy, + saveSwimlaneGroupBy, + type Swimlane, + type SwimlaneGroupBy, +} from "@/lib/tasks-board-swimlanes"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -354,6 +362,7 @@ function SortableCard({ columns, subtaskProgress, face, + lanePriority, onMove, onEdit, }: { @@ -361,11 +370,18 @@ function SortableCard({ columns: AiProjectColumn[]; subtaskProgress?: { total: number; done: number }; face: CardFaceVisibility; + lanePriority?: number | null; onMove: (id: string, columnId: string) => void; onEdit: (card: AiProjectCard) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = - useSortable({ id: card.id, data: { columnId: card.column_id } }); + useSortable({ + id: card.id, + data: { + columnId: card.column_id, + lanePriority: lanePriority ?? undefined, + }, + }); const tags = useMemo(() => parseTags(card.tags_json), [card.tags_json]); const gh = useMemo( () => parseGithubCardMeta(card.context_json), @@ -1497,6 +1513,9 @@ export function ProjectsBoard({ const [filter, setFilter] = useState(() => loadBoardFilter(boardKey) ); + const [groupBy, setGroupBy] = useState(() => + loadSwimlaneGroupBy(boardKey) + ); const { clearReviewUnread } = useIntelligence(); const readOnly = scopeReadOnly(scope); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); @@ -1504,6 +1523,7 @@ export function ProjectsBoard({ useEffect(() => { setFace(loadCardFaceVisibility(boardKey)); setFilter(loadBoardFilter(boardKey)); + setGroupBy(loadSwimlaneGroupBy(boardKey)); const prefs = loadCreatePrefs(boardKey); setCreateMode(prefs.mode); setCreateRepo(prefs.repo || defaultGithubRepo); @@ -1579,13 +1599,40 @@ export function ProjectsBoard({ return map; }, [cards, columns]); - const onMove = async (id: string, columnId: string) => { + const onMove = async ( + id: string, + columnId: string, + lanePriority?: number | null + ) => { if (readOnly) return; try { if (isUserScope(scope)) { await moveUserProjectCard(id, columnId); + if ( + groupBy === "priority" && + lanePriority != null && + Number.isFinite(lanePriority) + ) { + const current = cards.find((c) => c.id === id); + if (current && (current.priority ?? 2) !== lanePriority) { + await updateUserProjectCard(id, { priority: lanePriority }); + } + } } else { await moveProjectCard(id, columnId, undefined, scope.agentId); + if ( + groupBy === "priority" && + lanePriority != null && + Number.isFinite(lanePriority) + ) { + const current = cards.find((c) => c.id === id); + if (current && (current.priority ?? 2) !== lanePriority) { + await updateProjectCard(id, { + priority: lanePriority, + agentId: scope.agentId, + }); + } + } } load(); } catch (err) { @@ -1597,7 +1644,10 @@ export function ProjectsBoard({ setActiveId(null); const cardId = String(e.active.id); const overCol = e.over?.data.current?.columnId as string | undefined; - if (overCol) void onMove(cardId, overCol); + const overPriority = e.over?.data.current?.lanePriority as + | number + | undefined; + if (overCol) void onMove(cardId, overCol, overPriority); }; const openCreate = () => { @@ -1690,9 +1740,85 @@ export function ProjectsBoard({ return ids.size; }, [cards, columns, filter]); + const lanes = useMemo( + () => buildSwimlanes(cards, groupBy), + [cards, groupBy] + ); + // Board shows top-level cards only (subtasks are managed inside the editor). - const byColumn = (colId: string) => - cardsForColumn(cards, colId, filter) as AiProjectCard[]; + const byColumn = (colId: string, lane?: Swimlane) => { + const pool = + groupBy === "none" || !lane + ? cards + : (cardsInSwimlane(cards, lane, groupBy) as AiProjectCard[]); + return cardsForColumn(pool, colId, filter) as AiProjectCard[]; + }; + + const visibleColumns = columns.filter( + (col) => filter.columns.length === 0 || filter.columns.includes(col.id) + ); + + const renderColumn = (col: AiProjectColumn, lane: Swimlane) => { + const visible = byColumn(col.id, lane); + const count = visible.length; + const totalInCol = ( + groupBy === "none" + ? cards + : cardsInSwimlane(cards, lane, groupBy) + ).filter((c) => c.column_id === col.id && !c.parent_card_id).length; + const wip = + col.wip_limit != null && col.wip_limit > 0 ? col.wip_limit : null; + const overWip = wip != null && totalInCol > wip; + return ( +
+
+
{col.name}
+
+ {wip != null + ? `${totalInCol}/${wip}` + : filterNarrowing && count !== totalInCol + ? `${count}/${totalInCol}` + : count} +
+
+ c.id)} + strategy={verticalListSortingStrategy} + > +
+ {visible.map((card) => ( +
+ + void onMove(id, columnId, lane.priority) + } + onEdit={openEditor} + /> +
+ ))} +
+
+
+ ); + }; return (
@@ -1954,6 +2080,23 @@ export function ProjectsBoard({ Sort: Manual + {filterIsActive ? (