Merge branch 'hai/hai-002'

# Conflicts:
#	packages/dashboard/app/components/TaskCard.tsx
This commit is contained in:
Dustin Byrne
2026-03-25 18:59:09 -04:00
10 changed files with 291 additions and 31 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import type { TaskDetail } from "@hai/core";
import { fetchConfig } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { CreateTaskModal } from "./components/CreateTaskModal";
@@ -11,7 +12,14 @@ import { ToastProvider, useToast } from "./hooks/useToast";
function AppInner() {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const [maxConcurrent, setMaxConcurrent] = useState(2);
const { tasks, createTask, moveTask, deleteTask, mergeTask } = useTasks();
useEffect(() => {
fetchConfig()
.then((cfg) => setMaxConcurrent(cfg.maxConcurrent))
.catch(() => {/* keep default */});
}, []);
const { toasts, addToast, removeToast } = useToast();
const handleCreateOpen = useCallback(() => setCreateModalOpen(true), []);
@@ -28,6 +36,7 @@ function AppInner() {
<Header onNewTask={handleCreateOpen} />
<Board
tasks={tasks}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}

View File

@@ -39,3 +39,7 @@ export function deleteTask(id: string): Promise<Task> {
export function mergeTask(id: string): Promise<MergeResult> {
return api<MergeResult>(`/tasks/${id}/merge`, { method: "POST" });
}
export function fetchConfig(): Promise<{ maxConcurrent: number }> {
return api<{ maxConcurrent: number }>("/config");
}

View File

@@ -5,12 +5,13 @@ import type { ToastType } from "../hooks/useToast";
interface BoardProps {
tasks: Task[];
maxConcurrent: number;
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
}
export function Board({ tasks, onMoveTask, onOpenDetail, addToast }: BoardProps) {
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast }: BoardProps) {
return (
<main className="board" id="board">
{COLUMNS.map((col) => (
@@ -18,6 +19,8 @@ export function Board({ tasks, onMoveTask, onOpenDetail, addToast }: BoardProps)
key={col}
column={col}
tasks={tasks.filter((t) => t.column === col)}
allTasks={tasks}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onOpenDetail={onOpenDetail}
addToast={addToast}

View File

@@ -2,17 +2,21 @@ import { useState, useCallback } from "react";
import type { Task, TaskDetail, Column as ColumnType } from "@hai/core";
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@hai/core";
import { TaskCard } from "./TaskCard";
import { WorktreeGroup } from "./WorktreeGroup";
import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
interface ColumnProps {
column: ColumnType;
tasks: Task[];
allTasks: Task[];
maxConcurrent: number;
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
}
export function Column({ column, tasks, onMoveTask, onOpenDetail, addToast }: ColumnProps) {
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -56,7 +60,25 @@ export function Column({ column, tasks, onMoveTask, onOpenDetail, addToast }: Co
</div>
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
<div className="column-body">
{tasks.length === 0 ? (
{column === "in-progress" ? (
(() => {
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
return groups.length === 0 ? (
<div className="empty-column">No tasks</div>
) : (
groups.map((group) => (
<WorktreeGroup
key={group.label}
label={group.label}
activeTasks={group.activeTasks}
queuedTasks={group.queuedTasks}
onOpenDetail={onOpenDetail}
addToast={addToast}
/>
))
);
})()
) : tasks.length === 0 ? (
<div className="empty-column">No tasks</div>
) : (
tasks.map((task) => (

View File

@@ -1,16 +1,16 @@
import { useCallback, useState } from "react";
import type { Task, TaskDetail, TaskStatus } from "@hai/core";
import { STATUS_LABELS, STATUS_COLORS } from "@hai/core";
import type { Task, TaskDetail } from "@hai/core";
import { fetchTaskDetail } from "../api";
import type { ToastType } from "../hooks/useToast";
interface TaskCardProps {
task: Task;
queued?: boolean;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
}
export function TaskCard({ task, onOpenDetail, addToast }: TaskCardProps) {
export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const handleDragStart = useCallback((e: React.DragEvent) => {
@@ -32,39 +32,27 @@ export function TaskCard({ task, onOpenDetail, addToast }: TaskCardProps) {
}
}, [task.id, onOpenDetail, addToast]);
const status: TaskStatus = task.status ?? "idle";
const showBadge = status !== "idle";
const isPulsing = status === "specifying" || status === "executing";
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}`;
return (
<div
className={`card${dragging ? " dragging" : ""}`}
className={cardClass}
data-id={task.id}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
draggable={!queued}
onDragStart={queued ? undefined : handleDragStart}
onDragEnd={queued ? undefined : handleDragEnd}
onClick={handleClick}
>
<span className="card-id">{task.id}</span>
{showBadge && (
<span
className={`card-status-badge${isPulsing ? " pulsing" : ""}`}
style={{
color: STATUS_COLORS[status],
background: STATUS_COLORS[status].startsWith("var(")
? `color-mix(in srgb, ${STATUS_COLORS[status]} 15%, transparent)`
: `${STATUS_COLORS[status]}26`,
}}
>
{STATUS_LABELS[status]}
</span>
)}
<div className="card-title">{task.title}</div>
{task.dependencies && task.dependencies.length > 0 && (
{((task.dependencies && task.dependencies.length > 0) || queued) && (
<div className="card-meta">
<span className="card-dep-badge">
{task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
</span>
{task.dependencies && task.dependencies.length > 0 && (
<span className="card-dep-badge">
{task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
</span>
)}
{queued && <span className="queued-badge"> Queued</span>}
</div>
)}
</div>

View File

@@ -0,0 +1,42 @@
import type { Task, TaskDetail } from "@hai/core";
import { TaskCard } from "./TaskCard";
import type { ToastType } from "../hooks/useToast";
interface WorktreeGroupProps {
label: string;
activeTasks: Task[];
queuedTasks: Task[];
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
}
export function WorktreeGroup({
label,
activeTasks,
queuedTasks,
onOpenDetail,
addToast,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
<div className="worktree-group-header">
<span className="worktree-icon">
{label === "Up Next" || label === "Unassigned" ? "📋" : "🌿"}
</span>
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} />
))}
{queuedTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
queued
onOpenDetail={onOpenDetail}
addToast={addToast}
/>
))}
</div>
);
}

View File

@@ -421,6 +421,60 @@ html, body {
to { opacity: 1; transform: translateY(0); }
}
/* === Worktree Groups === */
.worktree-group {
background: var(--surface);
border-left: 3px solid var(--in-progress);
border-radius: var(--radius);
padding: 8px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.worktree-group-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
padding: 2px 4px;
}
.worktree-icon {
font-size: 12px;
line-height: 1;
}
.worktree-label {
font-family: "SF Mono", Monaco, Consolas, monospace;
}
/* Queued preview cards */
.card.queued {
opacity: 0.55;
border-style: dashed;
cursor: default;
}
.card.queued:hover {
cursor: default;
}
.card.queued:active {
cursor: default;
}
.queued-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--todo);
}
/* === Empty state === */
.empty-column {
display: flex;

View File

@@ -0,0 +1,131 @@
import type { Task } from "@hai/core";
export interface WorktreeGroupData {
label: string;
activeTasks: Task[];
queuedTasks: Task[];
}
/**
* Extract a clean display name from a worktree path.
* e.g. ".worktrees/HAI-001" → "HAI-001", "/path/to/hai/hai-001" → "hai-001"
*/
export function getWorktreeLabel(worktreePath: string): string {
// Take the last segment of the path
const segments = worktreePath.replace(/\/+$/, "").split("/");
return segments[segments.length - 1] || worktreePath;
}
/**
* Topological sort of tasks by dependency order.
* Mirrors resolveDependencyOrder from @hai/core but inlined to avoid
* build alias issues (Vite aliases @hai/core to types.ts only).
*/
function resolveDependencyOrder(tasks: Task[]): string[] {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const ordered: string[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
function visit(id: string): void {
if (visited.has(id)) return;
if (visiting.has(id)) return;
visiting.add(id);
const task = taskMap.get(id);
if (task) {
for (const depId of task.dependencies) {
if (taskMap.has(depId)) visit(depId);
}
}
visiting.delete(id);
visited.add(id);
ordered.push(id);
}
for (const task of tasks) visit(task.id);
return ordered;
}
/**
* Group in-progress tasks by worktree and distribute queued todo tasks
* as visual previews across the worktree groups.
*/
export function groupByWorktree(
inProgressTasks: Task[],
allTasks: Task[],
maxConcurrent: number,
): WorktreeGroupData[] {
// Separate assigned vs unassigned in-progress tasks
const assigned = inProgressTasks.filter((t) => t.worktree);
const unassigned = inProgressTasks.filter((t) => !t.worktree);
// Group assigned tasks by worktree
const worktreeMap = new Map<string, Task[]>();
for (const task of assigned) {
const key = task.worktree!;
const list = worktreeMap.get(key) || [];
list.push(task);
worktreeMap.set(key, list);
}
// Find queued todo tasks: "todo" tasks with all deps satisfied (done or in-review)
const taskById = new Map(allTasks.map((t) => [t.id, t]));
const todoTasks = allTasks.filter((t) => t.column === "todo");
const eligible = todoTasks.filter((t) =>
t.dependencies.every((depId) => {
const dep = taskById.get(depId);
return dep && (dep.column === "done" || dep.column === "in-review");
}),
);
// Order eligible tasks by dependency order
const orderedIds = resolveDependencyOrder(eligible);
const orderedEligible = orderedIds
.map((id) => taskById.get(id))
.filter((t): t is Task => t !== undefined && eligible.includes(t));
// Build groups from worktree map
const groups: WorktreeGroupData[] = [];
const worktreeKeys = Array.from(worktreeMap.keys());
for (const key of worktreeKeys) {
groups.push({
label: getWorktreeLabel(key),
activeTasks: worktreeMap.get(key)!,
queuedTasks: [],
});
}
// Add unassigned group if needed
if (unassigned.length > 0) {
groups.push({
label: "Unassigned",
activeTasks: unassigned,
queuedTasks: [],
});
}
// 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,
});
}
}
return groups;
}

View File

@@ -6,6 +6,11 @@ import type { ServerOptions } from "./server.js";
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
// Scheduler config
router.get("/config", (_req, res) => {
res.json({ maxConcurrent: options?.maxConcurrent ?? 2 });
});
// List all tasks
router.get("/tasks", async (_req, res) => {
try {

View File

@@ -11,6 +11,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
export interface ServerOptions {
/** Custom merge handler — when provided, used instead of store.mergeTask */
onMerge?: (taskId: string) => Promise<MergeResult>;
/** Maximum concurrent worktrees / execution slots (default 2) */
maxConcurrent?: number;
}
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {