feat(HAI-002): build modal components

This commit is contained in:
Dustin Byrne
2026-03-25 18:26:11 -04:00
parent b9993f96fb
commit 938e2dad34
2 changed files with 231 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { useState, useCallback, useEffect, useRef } from "react";
import type { Task, TaskCreateInput } from "@hai/core";
import type { ToastType } from "../hooks/useToast";
interface CreateTaskModalProps {
onClose: () => void;
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void;
}
export function CreateTaskModal({ onClose, onCreateTask, addToast }: CreateTaskModalProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [deps, setDeps] = useState("");
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setTimeout(() => titleRef.current?.focus(), 100);
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) return;
const dependencies = deps
.split(",")
.map((s) => s.trim())
.filter(Boolean);
try {
const task = await onCreateTask({
title: trimmedTitle,
description: description.trim() || undefined,
dependencies: dependencies.length ? dependencies : undefined,
});
addToast(`Created ${task.id}`, "success");
onClose();
} catch (err: any) {
addToast(err.message, "error");
}
},
[title, description, deps, onCreateTask, addToast, onClose],
);
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal">
<div className="modal-header">
<h3>New Task</h3>
<button className="modal-close" onClick={onClose}>
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="task-title">Title</label>
<input
ref={titleRef}
type="text"
id="task-title"
placeholder="What needs to be done?"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="task-desc">
Description <span className="optional">(optional)</span>
</label>
<textarea
id="task-desc"
rows={4}
placeholder="Add context, requirements, or rough notes..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="task-deps">
Dependencies <span className="optional">(comma-separated IDs)</span>
</label>
<input
type="text"
id="task-deps"
placeholder="HAI-001, HAI-002"
value={deps}
onChange={(e) => setDeps(e.target.value)}
/>
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Create in Triage
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect } from "react";
import type { Task, TaskDetail, Column } from "@hai/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
import type { ToastType } from "../hooks/useToast";
interface TaskDetailModalProps {
task: TaskDetail;
onClose: () => void;
onMoveTask: (id: string, column: Column) => Promise<Task>;
onDeleteTask: (id: string) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void;
}
export function TaskDetailModal({
task,
onClose,
onMoveTask,
onDeleteTask,
addToast,
}: TaskDetailModalProps) {
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
const handleMove = useCallback(
async (column: Column) => {
try {
await onMoveTask(task.id, column);
onClose();
addToast(`Moved to ${COLUMN_LABELS[column]}`, "success");
} catch (err: any) {
addToast(err.message, "error");
}
},
[task.id, onMoveTask, onClose, addToast],
);
const handleDelete = useCallback(async () => {
if (!confirm(`Delete ${task.id}?`)) return;
try {
await onDeleteTask(task.id);
onClose();
addToast(`Deleted ${task.id}`, "info");
} catch (err: any) {
addToast(err.message, "error");
}
}, [task.id, onDeleteTask, onClose, addToast]);
const transitions = VALID_TRANSITIONS[task.column] || [];
return (
<div className="modal-overlay open" onClick={handleOverlayClick}>
<div className="modal modal-lg">
<div className="modal-header">
<div className="detail-title-row">
<span className="detail-id">{task.id}</span>
<span className={`detail-column-badge badge-${task.column}`}>
{COLUMN_LABELS[task.column]}
</span>
</div>
<button className="modal-close" onClick={onClose}>
&times;
</button>
</div>
<div className="detail-body">
<h2 className="detail-title">{task.title}</h2>
<div className="detail-meta">
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
{new Date(task.updatedAt).toLocaleDateString()}
</div>
<div className="detail-section">
<h4>PROMPT.md</h4>
<pre className="detail-prompt">{task.prompt || "(no prompt)"}</pre>
</div>
{task.dependencies && task.dependencies.length > 0 && (
<div className="detail-deps">
<h4>Dependencies</h4>
<ul className="detail-dep-list">
{task.dependencies.map((dep) => (
<li key={dep}>{dep}</li>
))}
</ul>
</div>
)}
</div>
<div className="modal-actions">
<button className="btn btn-danger btn-sm" onClick={handleDelete}>
Delete
</button>
<div style={{ flex: 1 }} />
{transitions.map((col) => (
<button key={col} className="btn btn-sm" onClick={() => handleMove(col)}>
Move to {COLUMN_LABELS[col]}
</button>
))}
</div>
</div>
</div>
);
}