feat: merge HAI-002 React dashboard + add merge flow

This commit is contained in:
Dustin Byrne
2026-03-25 18:38:34 -04:00
23 changed files with 2258 additions and 33 deletions

1
.DONE
View File

@@ -1 +0,0 @@
done

View File

@@ -0,0 +1,63 @@
import { useState, useCallback } from "react";
import type { TaskDetail } from "@hai/core";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { CreateTaskModal } from "./components/CreateTaskModal";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { ToastContainer } from "./components/ToastContainer";
import { useTasks } from "./hooks/useTasks";
import { ToastProvider, useToast } from "./hooks/useToast";
function AppInner() {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const { tasks, createTask, moveTask, deleteTask, mergeTask } = useTasks();
const { toasts, addToast, removeToast } = useToast();
const handleCreateOpen = useCallback(() => setCreateModalOpen(true), []);
const handleCreateClose = useCallback(() => setCreateModalOpen(false), []);
const handleDetailOpen = useCallback((task: TaskDetail) => {
setDetailTask(task);
}, []);
const handleDetailClose = useCallback(() => setDetailTask(null), []);
return (
<>
<Header onNewTask={handleCreateOpen} />
<Board
tasks={tasks}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
/>
{createModalOpen && (
<CreateTaskModal
onClose={handleCreateClose}
onCreateTask={createTask}
addToast={addToast}
/>
)}
{detailTask && (
<TaskDetailModal
task={detailTask}
onClose={handleDetailClose}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}
addToast={addToast}
/>
)}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}
export function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
);
}

View File

@@ -0,0 +1,41 @@
import type { Task, TaskDetail, TaskCreateInput, Column, MergeResult } from "@hai/core";
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json" },
...opts,
});
const data = await res.json();
if (!res.ok) throw new Error((data as { error?: string }).error || "Request failed");
return data as T;
}
export function fetchTasks(): Promise<Task[]> {
return api<Task[]>("/tasks");
}
export function fetchTaskDetail(id: string): Promise<TaskDetail> {
return api<TaskDetail>(`/tasks/${id}`);
}
export function createTask(input: TaskCreateInput): Promise<Task> {
return api<Task>("/tasks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function moveTask(id: string, column: Column): Promise<Task> {
return api<Task>(`/tasks/${id}/move`, {
method: "POST",
body: JSON.stringify({ column }),
});
}
export function deleteTask(id: string): Promise<Task> {
return api<Task>(`/tasks/${id}`, { method: "DELETE" });
}
export function mergeTask(id: string): Promise<MergeResult> {
return api<MergeResult>(`/tasks/${id}/merge`, { method: "POST" });
}

View File

@@ -0,0 +1,28 @@
import type { Task, TaskDetail, Column as ColumnType } from "@hai/core";
import { COLUMNS } from "@hai/core";
import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast";
interface BoardProps {
tasks: Task[];
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) {
return (
<main className="board" id="board">
{COLUMNS.map((col) => (
<Column
key={col}
column={col}
tasks={tasks.filter((t) => t.column === col)}
onMoveTask={onMoveTask}
onOpenDetail={onOpenDetail}
addToast={addToast}
/>
))}
</main>
);
}

View File

@@ -0,0 +1,69 @@
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 type { ToastType } from "../hooks/useToast";
interface ColumnProps {
column: ColumnType;
tasks: Task[];
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) {
const [dragOver, setDragOver] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const el = e.currentTarget as HTMLElement;
if (!el.contains(e.relatedTarget as Node)) {
setDragOver(false);
}
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const taskId = e.dataTransfer.getData("text/plain");
if (!taskId) return;
try {
await onMoveTask(taskId, column);
} catch (err: any) {
addToast(err.message, "error");
}
}, [column, onMoveTask, addToast]);
return (
<div
className={`column${dragOver ? " drag-over" : ""}`}
data-column={column}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="column-header">
<div className={`column-dot dot-${column}`} />
<h2>{COLUMN_LABELS[column]}</h2>
<span className="column-count">{tasks.length}</span>
</div>
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
<div className="column-body">
{tasks.length === 0 ? (
<div className="empty-column">No tasks</div>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} />
))
)}
</div>
</div>
);
}

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,17 @@
interface HeaderProps {
onNewTask: () => void;
}
export function Header({ onNewTask }: HeaderProps) {
return (
<header className="header">
<div className="header-left">
<h1 className="logo">hai</h1>
<span className="logo-sub">board</span>
</div>
<button className="btn btn-primary" onClick={onNewTask}>
+ New Task
</button>
</header>
);
}

View File

@@ -0,0 +1,54 @@
import { useCallback, useState } from "react";
import type { Task, TaskDetail } from "@hai/core";
import { fetchTaskDetail } from "../api";
import type { ToastType } from "../hooks/useToast";
interface TaskCardProps {
task: Task;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
}
export function TaskCard({ task, onOpenDetail, addToast }: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const handleDragStart = useCallback((e: React.DragEvent) => {
e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.effectAllowed = "move";
setDragging(true);
}, [task.id]);
const handleDragEnd = useCallback(() => {
setDragging(false);
}, []);
const handleClick = useCallback(async () => {
try {
const detail = await fetchTaskDetail(task.id);
onOpenDetail(detail);
} catch (err: any) {
addToast(err.message, "error");
}
}, [task.id, onOpenDetail, addToast]);
return (
<div
className={`card${dragging ? " dragging" : ""}`}
data-id={task.id}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={handleClick}
>
<span className="card-id">{task.id}</span>
<div className="card-title">{task.title}</div>
{task.dependencies && task.dependencies.length > 0 && (
<div className="card-meta">
<span className="card-dep-badge">
{task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect } from "react";
import type { Task, TaskDetail, Column, MergeResult } 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>;
onMergeTask: (id: string) => Promise<MergeResult>;
addToast: (message: string, type?: ToastType) => void;
}
export function TaskDetailModal({
task,
onClose,
onMoveTask,
onDeleteTask,
onMergeTask,
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 handleMerge = useCallback(async () => {
if (!confirm(`Merge ${task.id} into the current branch?`)) return;
try {
const result = await onMergeTask(task.id);
onClose();
const msg = result.merged
? `Merged ${task.id} (branch: ${result.branch})`
: `Closed ${task.id} (${result.error || "no branch to merge"})`;
addToast(msg, "success");
} catch (err: any) {
addToast(err.message, "error");
}
}, [task.id, onMergeTask, 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 }} />
{task.column === "in-review" ? (
<>
<button className="btn btn-sm" onClick={() => handleMove("in-progress")}>
Back to In Progress
</button>
<button className="btn btn-primary btn-sm" onClick={handleMerge}>
Merge &amp; Close
</button>
</>
) : (
transitions.map((col) => (
<button key={col} className="btn btn-sm" onClick={() => handleMove(col)}>
Move to {COLUMN_LABELS[col]}
</button>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import type { Toast } from "../hooks/useToast";
interface ToastContainerProps {
toasts: Toast[];
onRemove: (id: number) => void;
}
export function ToastContainer({ toasts }: ToastContainerProps) {
return (
<div className="toast-container" id="toasts">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
{toast.message}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { Task, Column, TaskCreateInput, MergeResult } from "@hai/core";
import * as api from "../api";
export function useTasks() {
const [tasks, setTasks] = useState<Task[]>([]);
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
// Fetch initial tasks
useEffect(() => {
api.fetchTasks().then(setTasks).catch(() => setTasks([]));
}, []);
// SSE live updates
useEffect(() => {
const es = new EventSource("/api/events");
es.addEventListener("task:created", (e) => {
const task: Task = JSON.parse(e.data);
setTasks((prev) => [...prev, task]);
});
es.addEventListener("task:moved", (e) => {
const { task }: { task: Task } = JSON.parse(e.data);
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
});
es.addEventListener("task:updated", (e) => {
const task: Task = JSON.parse(e.data);
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
});
es.addEventListener("task:deleted", (e) => {
const task: Task = JSON.parse(e.data);
setTasks((prev) => prev.filter((t) => t.id !== task.id));
});
es.addEventListener("task:merged", (e) => {
const { task }: { task: Task } = JSON.parse(e.data);
setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t)));
});
es.addEventListener("error", () => {
setTimeout(() => {
if (es.readyState === EventSource.CLOSED) {
// Will reconnect via new effect cycle
}
}, 3000);
});
return () => es.close();
}, []);
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
return api.createTask(input);
}, []);
const moveTask = useCallback(async (id: string, column: Column): Promise<Task> => {
return api.moveTask(id, column);
}, []);
const deleteTask = useCallback(async (id: string): Promise<Task> => {
return api.deleteTask(id);
}, []);
const mergeTask = useCallback(async (id: string): Promise<MergeResult> => {
return api.mergeTask(id);
}, []);
return { tasks, createTask, moveTask, deleteTask, mergeTask };
}

View File

@@ -0,0 +1,42 @@
import { useState, useCallback, useRef, createContext, useContext } from "react";
import type { ReactNode } from "react";
import React from "react";
export type ToastType = "success" | "error" | "info";
export interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextValue {
toasts: Toast[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: number) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(0);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback((message: string, type: ToastType = "info") => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => removeToast(id), 4000);
}, [removeToast]);
return React.createElement(ToastContext.Provider, { value: { toasts, addToast, removeToast } }, children);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>hai — board</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,414 @@
/* === Reset & Tokens === */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--card: #21262d;
--card-hover: #282e36;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--text-dim: #484f58;
--triage: #d29922;
--todo: #58a6ff;
--in-progress: #bc8cff;
--in-review: #3fb950;
--done: #8b949e;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 4px 24px rgba(0,0,0,0.4);
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* === Header === */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.header-left { display: flex; align-items: baseline; gap: 8px; }
.logo { font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
.logo-sub { font-size: 13px; color: var(--text-muted); font-weight: 400; }
/* === Buttons === */
.btn {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover { background: var(--card-hover); border-color: var(--text-muted); }
.btn-primary {
background: #238636;
border-color: #2ea043;
color: #fff;
}
.btn-primary:hover { background: #2ea043; }
.btn-danger {
background: #da3633;
border-color: #f85149;
color: #fff;
}
.btn-danger:hover { background: #f85149; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* === Board === */
.board {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
padding: 16px 24px;
height: calc(100vh - 57px);
overflow: hidden;
}
.column {
display: flex;
flex-direction: column;
background: var(--surface);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
overflow: hidden;
min-height: 0;
transition: border-color 0.2s;
}
.column.drag-over {
border-color: var(--todo);
box-shadow: inset 0 0 0 1px var(--todo);
}
.column-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 14px 0;
}
.column-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot-triage { background: var(--triage); }
.dot-todo { background: var(--todo); }
.dot-in-progress { background: var(--in-progress); }
.dot-in-review { background: var(--in-review); }
.dot-done { background: var(--done); }
.column-header h2 {
font-size: 14px;
font-weight: 600;
flex: 1;
}
.column-count {
font-size: 12px;
color: var(--text-muted);
background: var(--card);
padding: 2px 8px;
border-radius: 10px;
min-width: 24px;
text-align: center;
}
.column-desc {
font-size: 11px;
color: var(--text-dim);
padding: 4px 14px 10px;
}
.column-body {
flex: 1;
overflow-y: auto;
padding: 4px 8px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.column-body::-webkit-scrollbar { width: 4px; }
.column-body::-webkit-scrollbar-track { background: transparent; }
.column-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
/* === Cards === */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: grab;
transition: background 0.15s, border-color 0.15s, transform 0.15s, opacity 0.2s;
user-select: none;
}
.card:hover {
background: var(--card-hover);
border-color: var(--text-muted);
}
.card:active { cursor: grabbing; }
.card.dragging { opacity: 0.4; transform: scale(0.98); }
.card-id {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
font-family: "SF Mono", Monaco, Consolas, monospace;
margin-bottom: 4px;
}
.card-title {
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
.card-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 11px;
color: var(--text-dim);
}
.card-dep-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--triage);
}
/* === Modals === */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 100;
justify-content: center;
align-items: flex-start;
padding-top: 10vh;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 480px;
max-height: 80vh;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
}
.modal-lg { width: 640px; }
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 15px; font-weight: 600; }
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover { color: var(--text); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border);
}
/* === Forms === */
.form-group { padding: 0 20px; margin-top: 16px; }
.form-group:last-of-type { margin-bottom: 8px; }
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.optional { font-weight: 400; text-transform: none; letter-spacing: 0; }
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group textarea:focus {
border-color: var(--todo);
}
.form-group textarea { resize: vertical; }
/* === Detail Modal === */
.detail-title-row { display: flex; align-items: center; gap: 10px; }
.detail-id {
font-family: "SF Mono", Monaco, Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.detail-column-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-triage { background: rgba(210,153,34,0.15); color: var(--triage); }
.badge-todo { background: rgba(88,166,255,0.15); color: var(--todo); }
.badge-in-progress { background: rgba(188,140,255,0.15); color: var(--in-progress); }
.badge-in-review { background: rgba(63,185,80,0.15); color: var(--in-review); }
.badge-done { background: rgba(139,148,158,0.15); color: var(--done); }
.detail-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.detail-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
.detail-meta {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 16px;
}
.detail-section { margin-top: 16px; }
.detail-section h4 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 8px;
}
.detail-prompt {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
font-size: 13px;
line-height: 1.6;
font-family: "SF Mono", Monaco, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-muted);
max-height: 320px;
overflow-y: auto;
}
.detail-deps { margin-top: 16px; }
.detail-deps h4 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 6px;
}
.detail-dep-list {
list-style: none;
font-size: 13px;
}
.detail-dep-list li {
padding: 4px 0;
color: var(--todo);
font-family: "SF Mono", Monaco, Consolas, monospace;
}
/* === Toasts === */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 200;
display: flex;
flex-direction: column-reverse;
gap: 8px;
}
.toast {
padding: 10px 16px;
border-radius: var(--radius);
font-size: 13px;
color: #fff;
animation: toast-in 0.25s ease-out;
box-shadow: var(--shadow);
}
.toast-success { background: #238636; }
.toast-error { background: #da3633; }
.toast-info { background: #1f6feb; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* === Empty state === */
.empty-column {
display: flex;
align-items: center;
justify-content: center;
height: 80px;
font-size: 12px;
color: var(--text-dim);
border: 1px dashed var(--border);
border-radius: var(--radius);
margin: 4px;
}

View File

@@ -6,15 +6,23 @@
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "vite build && tsc",
"typecheck": "tsc --noEmit" "build:client": "vite build",
"dev:client": "vite",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
}, },
"dependencies": { "dependencies": {
"@hai/core": "workspace:*", "@hai/core": "workspace:*",
"express": "^5.1.0" "express": "^5.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"typescript": "^5.7.0" "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
} }
} }

View File

@@ -15,16 +15,6 @@ export function createApiRoutes(store: TaskStore): Router {
} }
}); });
// Get single task with prompt content
router.get("/tasks/:id", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
res.json(task);
} catch (err: any) {
res.status(404).json({ error: `Task ${req.params.id} not found` });
}
});
// Create task // Create task
router.post("/tasks", async (req, res) => { router.post("/tasks", async (req, res) => {
try { try {
@@ -63,6 +53,29 @@ export function createApiRoutes(store: TaskStore): Router {
} }
}); });
// Merge task (in-review → done, merges branch + cleans worktree)
router.post("/tasks/:id/merge", async (req, res) => {
try {
const result = await store.mergeTask(req.params.id);
res.json(result);
} catch (err: any) {
const status = err.message.includes("Cannot merge") ? 400
: err.message.includes("Merge conflict") ? 409
: 500;
res.status(status).json({ error: err.message });
}
});
// Get single task with prompt content
router.get("/tasks/:id", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
res.json(task);
} catch (err: any) {
res.status(404).json({ error: `Task ${req.params.id} not found` });
}
});
// Update task // Update task
router.patch("/tasks/:id", async (req, res) => { router.patch("/tasks/:id", async (req, res) => {
try { try {
@@ -78,19 +91,6 @@ export function createApiRoutes(store: TaskStore): Router {
} }
}); });
// Merge task (in-review → done, merges branch + cleans worktree)
router.post("/tasks/:id/merge", async (req, res) => {
try {
const result = await store.mergeTask(req.params.id);
res.json(result);
} catch (err: any) {
const status = err.message.includes("Cannot merge") ? 400
: err.message.includes("Merge conflict") ? 409
: 500;
res.status(status).json({ error: err.message });
}
});
// Delete task // Delete task
router.delete("/tasks/:id", async (req, res) => { router.delete("/tasks/:id", async (req, res) => {
try { try {

View File

@@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { TaskStore } from "@hai/core"; import type { TaskStore } from "@hai/core";
import { createApiRoutes } from "./routes.js"; import { createApiRoutes } from "./routes.js";
@@ -7,11 +8,18 @@ import { createSSE } from "./sse.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
export function createServer(store: TaskStore) { export function createServer(store: TaskStore): ReturnType<typeof express> {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(express.static(join(__dirname, "..", "public"))); // Serve built React app — check both possible locations
const clientDir = existsSync(join(__dirname, "..", "dist", "client"))
? join(__dirname, "..", "dist", "client")
: existsSync(join(__dirname, "..", "client"))
? join(__dirname, "..", "client")
: join(__dirname, "..", "public"); // fallback to old public dir
app.use(express.static(clientDir));
// SSE endpoint // SSE endpoint
app.get("/api/events", createSSE(store)); app.get("/api/events", createSSE(store));
@@ -21,7 +29,7 @@ export function createServer(store: TaskStore) {
// SPA fallback // SPA fallback
app.get("/{*splat}", (_req, res) => { app.get("/{*splat}", (_req, res) => {
res.sendFile(join(__dirname, "..", "public", "index.html")); res.sendFile(join(clientDir, "index.html"));
}); });
return app; return app;

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"module": "ESNext",
"noEmit": true
},
"include": ["app"]
}

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src",
"jsx": "react-jsx"
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "node:path";
export default defineConfig({
root: "app",
plugins: [react()],
resolve: {
alias: {
"@hai/core": resolve(__dirname, "../core/src/types.ts"),
},
},
build: {
outDir: "../dist/client",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
});

1079
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
// test