feat(HAI-057): add task dependency editing to dashboard UI

- Add dependencies field support to store updateTask method
- Wire dependencies through PATCH route and client API
- Add dependency editing UI (add/remove) to TaskDetailModal
- Pass tasks prop from App to TaskDetailModal for dependency picker
- Add tests for dependency CRUD in modal and API layer
This commit is contained in:
Dustin Byrne
2026-03-26 00:15:29 -04:00
parent e41d3936d6
commit efda541713
9 changed files with 318 additions and 11 deletions

View File

@@ -74,6 +74,7 @@ function AppInner() {
{detailTask && (
<TaskDetailModal
task={detailTask}
tasks={tasks}
onClose={handleDetailClose}
onMoveTask={moveTask}
onDeleteTask={deleteTask}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchTaskDetail } from "./api";
import type { TaskDetail } from "@hai/core";
import { fetchTaskDetail, updateTask } from "./api";
import type { Task, TaskDetail } from "@hai/core";
const FAKE_DETAIL: TaskDetail = {
id: "HAI-001",
@@ -63,3 +63,42 @@ describe("fetchTaskDetail", () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2); // initial + 1 retry
});
});
describe("updateTask", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_TASK: Task = {
id: "HAI-001",
description: "Test",
column: "in-progress",
dependencies: ["HAI-002"],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("sends PATCH with dependencies and returns updated task", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
const result = await updateTask("HAI-001", { dependencies: ["HAI-002"] });
expect(result.dependencies).toEqual(["HAI-002"]);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/HAI-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ dependencies: ["HAI-002"] }),
});
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not found" }));
await expect(updateTask("HAI-001", { dependencies: [] })).rejects.toThrow("Not found");
});
});

View File

@@ -37,6 +37,13 @@ export function createTask(input: TaskCreateInput): Promise<Task> {
});
}
export function updateTask(id: string, updates: { title?: string; description?: string; prompt?: string; dependencies?: string[] }): Promise<Task> {
return api<Task>(`/tasks/${id}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
export function moveTask(id: string, column: Column): Promise<Task> {
return api<Task>(`/tasks/${id}/move`, {
method: "POST",

View File

@@ -3,7 +3,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
import { uploadAttachment, deleteAttachment } from "../api";
import { uploadAttachment, deleteAttachment, updateTask } from "../api";
import type { ToastType } from "../hooks/useToast";
function formatTimestamp(iso: string): string {
@@ -29,6 +29,7 @@ function formatBytes(bytes: number): string {
interface TaskDetailModalProps {
task: TaskDetail;
tasks?: Task[];
onClose: () => void;
onMoveTask: (id: string, column: Column) => Promise<Task>;
onDeleteTask: (id: string) => Promise<Task>;
@@ -37,8 +38,13 @@ interface TaskDetailModalProps {
addToast: (message: string, type?: ToastType) => void;
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
export function TaskDetailModal({
task,
tasks = [],
onClose,
onMoveTask,
onDeleteTask,
@@ -48,6 +54,8 @@ export function TaskDetailModal({
}: TaskDetailModalProps) {
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
const [showDepDropdown, setShowDepDropdown] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -181,6 +189,30 @@ export function TaskDetailModal({
}
}, [task.id, addToast]);
const handleAddDep = useCallback(async (depId: string) => {
const newDeps = [...dependencies, depId];
setDependencies(newDeps);
try {
await updateTask(task.id, { dependencies: newDeps });
} catch (err: any) {
setDependencies(dependencies);
addToast(err.message, "error");
}
}, [task.id, dependencies, addToast]);
const handleRemoveDep = useCallback(async (depId: string) => {
const newDeps = dependencies.filter((d) => d !== depId);
setDependencies(newDeps);
try {
await updateTask(task.id, { dependencies: newDeps });
} catch (err: any) {
setDependencies(dependencies);
addToast(err.message, "error");
}
}, [task.id, dependencies, addToast]);
const availableTasks = tasks.filter((t) => t.id !== task.id && !dependencies.includes(t.id));
const transitions = VALID_TRANSITIONS[task.column] || [];
return (
@@ -286,16 +318,66 @@ export function TaskDetailModal({
{uploading ? "Uploading…" : "Attach Screenshot"}
</button>
</div>
{task.dependencies && task.dependencies.length > 0 && (
<div className="detail-deps">
<h4>Dependencies</h4>
<div className="detail-deps">
<h4>Dependencies</h4>
{dependencies.length > 0 ? (
<ul className="detail-dep-list">
{task.dependencies.map((dep) => (
<li key={dep}>{dep}</li>
{dependencies.map((dep) => (
<li key={dep}>
{dep}
<button
className="dep-remove-btn"
onClick={() => handleRemoveDep(dep)}
title={`Remove dependency ${dep}`}
style={{
marginLeft: "6px",
background: "none",
border: "none",
color: "var(--text-secondary, #888)",
cursor: "pointer",
fontSize: "14px",
padding: "0 4px",
}}
>
×
</button>
</li>
))}
</ul>
) : (
<div style={{ opacity: 0.5, marginBottom: "8px" }}>(no dependencies)</div>
)}
<div className="dep-trigger-wrap" style={{ position: "relative" }}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => setShowDepDropdown((v) => !v)}
>
Add Dependency
</button>
{showDepDropdown && (
<div className="dep-dropdown">
{availableTasks.length === 0 ? (
<div className="dep-dropdown-empty">No available tasks</div>
) : (
availableTasks.map((t) => (
<div
key={t.id}
className="dep-dropdown-item"
onClick={() => {
handleAddDep(t.id);
setShowDepDropdown(false);
}}
>
<span className="dep-dropdown-id">{t.id}</span>
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
</div>
))
)}
</div>
)}
</div>
)}
</div>
<div className="detail-section detail-activity">
<h4>Activity</h4>
{task.log && task.log.length > 0 ? (

View File

@@ -6,6 +6,7 @@ import type { TaskDetail, Column, MergeResult, Task } from "@hai/core";
vi.mock("../../api", () => ({
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
updateTask: vi.fn().mockResolvedValue({}),
}));
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
@@ -394,6 +395,95 @@ describe("TaskDetailModal", () => {
});
});
it("renders (no dependencies) when dependencies is empty", () => {
render(
<TaskDetailModal
task={makeTask({ dependencies: [] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByText("(no dependencies)")).toBeTruthy();
});
it("renders dependency list when dependencies exist", () => {
render(
<TaskDetailModal
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByText("HAI-001")).toBeTruthy();
expect(screen.getByText("HAI-002")).toBeTruthy();
expect(screen.queryByText("(no dependencies)")).toBeNull();
});
it("can add a dependency via the dropdown", async () => {
const { updateTask } = await import("../../api");
const allTasks: Task[] = [
{ id: "HAI-001", description: "Dep 1", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
{ id: "HAI-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" },
];
render(
<TaskDetailModal
task={makeTask({ dependencies: [] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Add Dependency"));
// Should show HAI-001 in the dropdown but not HAI-099 (self is excluded)
const dropdown = document.querySelector(".dep-dropdown")!;
expect(dropdown).toBeTruthy();
expect(dropdown.textContent).toContain("HAI-001");
expect(dropdown.querySelectorAll(".dep-dropdown-item")).toHaveLength(1);
fireEvent.click(screen.getByText("HAI-001"));
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-001"] });
});
});
it("can remove a dependency", async () => {
const { updateTask } = await import("../../api");
render(
<TaskDetailModal
task={makeTask({ dependencies: ["HAI-001", "HAI-002"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
const removeButtons = screen.getAllByTitle(/Remove dependency/);
fireEvent.click(removeButtons[0]); // Remove HAI-001
await waitFor(() => {
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-002"] });
});
});
});
});
it("activity list does not have nested scroll constraints", () => {
const { container } = render(
<TaskDetailModal

View File

@@ -199,6 +199,55 @@ describe("POST /tasks/:id/retry", () => {
});
});
describe("PATCH /tasks/:id", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("forwards dependencies to store.updateTask", async () => {
const updatedTask = { ...FAKE_TASK_DETAIL, dependencies: ["HAI-002"] };
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(updatedTask);
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/HAI-001", JSON.stringify({ dependencies: ["HAI-002"] }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
title: undefined,
description: undefined,
prompt: undefined,
dependencies: ["HAI-002"],
});
expect(res.body.dependencies).toEqual(["HAI-002"]);
});
it("forwards title and description without dependencies", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/HAI-001", JSON.stringify({ title: "New" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("HAI-001", {
title: "New",
description: undefined,
prompt: undefined,
dependencies: undefined,
});
});
});
describe("Attachment routes", () => {
const FAKE_ATTACHMENT: TaskAttachment = {
filename: "1234-screenshot.png",

View File

@@ -194,11 +194,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Update task
router.patch("/tasks/:id", async (req, res) => {
try {
const { title, description, prompt } = req.body;
const { title, description, prompt, dependencies } = req.body;
const task = await store.updateTask(req.params.id, {
title,
description,
prompt,
dependencies,
});
res.json(task);
} catch (err: any) {