feat: merge HAI-002 React dashboard + add merge flow
This commit is contained in:
63
packages/dashboard/app/App.tsx
Normal file
63
packages/dashboard/app/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
packages/dashboard/app/api.ts
Normal file
41
packages/dashboard/app/api.ts
Normal 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" });
|
||||||
|
}
|
||||||
28
packages/dashboard/app/components/Board.tsx
Normal file
28
packages/dashboard/app/components/Board.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
packages/dashboard/app/components/Column.tsx
Normal file
69
packages/dashboard/app/components/Column.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
packages/dashboard/app/components/CreateTaskModal.tsx
Normal file
120
packages/dashboard/app/components/CreateTaskModal.tsx
Normal 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}>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
packages/dashboard/app/components/Header.tsx
Normal file
17
packages/dashboard/app/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
packages/dashboard/app/components/TaskCard.tsx
Normal file
54
packages/dashboard/app/components/TaskCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
packages/dashboard/app/components/TaskDetailModal.tsx
Normal file
138
packages/dashboard/app/components/TaskDetailModal.tsx
Normal 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}>
|
||||||
|
×
|
||||||
|
</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 & Close
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
transitions.map((col) => (
|
||||||
|
<button key={col} className="btn btn-sm" onClick={() => handleMove(col)}>
|
||||||
|
Move to {COLUMN_LABELS[col]}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/dashboard/app/components/ToastContainer.tsx
Normal file
18
packages/dashboard/app/components/ToastContainer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
packages/dashboard/app/hooks/useTasks.ts
Normal file
72
packages/dashboard/app/hooks/useTasks.ts
Normal 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 };
|
||||||
|
}
|
||||||
42
packages/dashboard/app/hooks/useToast.ts
Normal file
42
packages/dashboard/app/hooks/useToast.ts
Normal 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;
|
||||||
|
}
|
||||||
12
packages/dashboard/app/index.html
Normal file
12
packages/dashboard/app/index.html
Normal 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>
|
||||||
10
packages/dashboard/app/main.tsx
Normal file
10
packages/dashboard/app/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
414
packages/dashboard/app/styles.css
Normal file
414
packages/dashboard/app/styles.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
11
packages/dashboard/tsconfig.app.json
Normal file
11
packages/dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"module": "ESNext",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["app"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
22
packages/dashboard/vite.config.ts
Normal file
22
packages/dashboard/vite.config.ts
Normal 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
1079
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
// test
|
|
||||||
Reference in New Issue
Block a user