Merge branch 'hai/hai-002'
# Conflicts: # packages/dashboard/app/components/TaskCard.tsx
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
packages/dashboard/app/components/WorktreeGroup.tsx
Normal file
42
packages/dashboard/app/components/WorktreeGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
131
packages/dashboard/app/utils/worktreeGrouping.ts
Normal file
131
packages/dashboard/app/utils/worktreeGrouping.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user