- Rename npm packages from @kb/* to @hai/* and update all workspace references - Rename CLI binary from kb to hai and config directory from .kb to .hai - Update dashboard UI branding, titles, and references from kb to hai - Update all test files, CI workflows, and documentation to reflect new naming - Run comprehensive grep verification to ensure no stale kb references remain
170 lines
6.2 KiB
TypeScript
170 lines
6.2 KiB
TypeScript
import { useCallback, useState } from "react";
|
|
import { Link, Clock, Layers } from "lucide-react";
|
|
import type { Task, TaskDetail, Column } from "@kb/core";
|
|
import { fetchTaskDetail, uploadAttachment } from "../api";
|
|
import type { ToastType } from "../hooks/useToast";
|
|
|
|
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
|
triage: "rgba(210,153,34,0.15)",
|
|
todo: "rgba(88,166,255,0.15)",
|
|
"in-progress": "rgba(188,140,255,0.15)",
|
|
"in-review": "rgba(63,185,80,0.15)",
|
|
done: "rgba(139,148,158,0.15)",
|
|
};
|
|
|
|
const COLUMN_TEXT_COLOR_MAP: Record<Column, string> = {
|
|
triage: "var(--triage)",
|
|
todo: "var(--todo)",
|
|
"in-progress": "var(--in-progress)",
|
|
"in-review": "var(--in-review)",
|
|
done: "var(--done)",
|
|
};
|
|
|
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
|
|
|
|
interface TaskCardProps {
|
|
task: Task;
|
|
queued?: boolean;
|
|
onOpenDetail: (task: TaskDetail) => void;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
}
|
|
|
|
export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps) {
|
|
const [dragging, setDragging] = useState(false);
|
|
const [fileDragOver, setFileDragOver] = 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 isFileDrag = useCallback((e: React.DragEvent) => {
|
|
return e.dataTransfer.types.includes("Files");
|
|
}, []);
|
|
|
|
const handleFileDragOver = useCallback((e: React.DragEvent) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
setFileDragOver(true);
|
|
}, [isFileDrag]);
|
|
|
|
const handleFileDragLeave = useCallback((e: React.DragEvent) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setFileDragOver(false);
|
|
}, [isFileDrag]);
|
|
|
|
const handleFileDrop = useCallback(async (e: React.DragEvent) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setFileDragOver(false);
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
for (const file of files) {
|
|
try {
|
|
await uploadAttachment(task.id, file);
|
|
addToast(`Attached ${file.name} to ${task.id}`, "success");
|
|
} catch (err: any) {
|
|
addToast(`Failed to attach ${file.name}: ${err.message}`, "error");
|
|
}
|
|
}
|
|
}, [task.id, isFileDrag, addToast]);
|
|
|
|
const handleClick = useCallback(async () => {
|
|
try {
|
|
const detail = await fetchTaskDetail(task.id);
|
|
onOpenDetail(detail);
|
|
} catch (err: any) {
|
|
addToast("Failed to load task details", "error");
|
|
}
|
|
}, [task.id, onOpenDetail, addToast]);
|
|
|
|
const isFailed = task.status === "failed";
|
|
const isPaused = task.paused === true;
|
|
const isAgentActive = !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
|
const isDraggable = !queued && !isPaused;
|
|
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}`;
|
|
|
|
return (
|
|
<div
|
|
className={cardClass}
|
|
data-id={task.id}
|
|
draggable={isDraggable}
|
|
onDragStart={isDraggable ? handleDragStart : undefined}
|
|
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
|
onDragOver={handleFileDragOver}
|
|
onDragLeave={handleFileDragLeave}
|
|
onDrop={handleFileDrop}
|
|
onClick={handleClick}
|
|
>
|
|
<div className="card-header">
|
|
<span className="card-id">{task.id}</span>
|
|
{isPaused && (
|
|
<span
|
|
className="card-status-badge"
|
|
style={{ background: "rgba(139,148,158,0.2)", color: "var(--text-secondary, #888)" }}
|
|
>
|
|
paused
|
|
</span>
|
|
)}
|
|
{!isPaused && task.status && task.status !== "queued" && (
|
|
<span
|
|
className={`card-status-badge${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}`}
|
|
style={isFailed
|
|
? { background: "rgba(218,54,51,0.15)", color: "#da3633" }
|
|
: { background: COLUMN_COLOR_MAP[task.column], color: COLUMN_TEXT_COLOR_MAP[task.column] }
|
|
}
|
|
>
|
|
{task.status}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="card-title">
|
|
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
|
|
</div>
|
|
{task.steps.length > 0 && (() => {
|
|
const completedSteps = task.steps.filter(s => s.status === "done").length;
|
|
const totalSteps = task.steps.length;
|
|
return (
|
|
<div className="card-progress">
|
|
<div className="card-progress-bar">
|
|
<div
|
|
className="card-progress-fill"
|
|
style={{
|
|
width: `${(completedSteps / totalSteps) * 100}%`,
|
|
backgroundColor: COLUMN_TEXT_COLOR_MAP[task.column],
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className="card-progress-label">{completedSteps}/{totalSteps}</span>
|
|
</div>
|
|
);
|
|
})()}
|
|
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
|
<div className="card-meta">
|
|
{task.dependencies && task.dependencies.length > 0 && (
|
|
<span className="card-dep-badge" data-tooltip={task.dependencies.join(", ")}>
|
|
<Link size={12} style={{ verticalAlign: 'middle' }} /> {task.dependencies.length} dep{task.dependencies.length > 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
{task.blockedBy && (
|
|
<span className="card-scope-badge" data-tooltip={`Blocked by ${task.blockedBy} (file overlap)`}>
|
|
<Layers size={12} style={{ verticalAlign: 'middle' }} /> {task.blockedBy}
|
|
</span>
|
|
)}
|
|
{(queued || task.status === "queued") && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: 'middle' }} /> Queued</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|