- Add project API methods and types (fetchProjects, registerProject, fetchProjectHealth, etc.) - Add ProjectCard component with health metrics, status badges, and pause/resume actions - Add server-side project management routes for multi-project orchestration - Add ActivityFeed component with grouped entries and project badges - Add SetupWizard component with 5-step project creation flow - Fix TypeScript errors in server routes for listProjects and getGlobalConcurrencyState
740 lines
26 KiB
TypeScript
740 lines
26 KiB
TypeScript
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
|
import { Link, Clock, Layers, Pencil, ChevronDown, Folder } from "lucide-react";
|
|
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
|
import { fetchTaskDetail, uploadAttachment } from "../api";
|
|
import { GitHubBadge } from "./GitHubBadge";
|
|
import { pickPreferredBadge } from "./TaskCardBadge";
|
|
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
|
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
|
import { useSessionFiles } from "../hooks/useSessionFiles";
|
|
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)",
|
|
archived: "rgba(120,120,120,0.1)",
|
|
};
|
|
|
|
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)",
|
|
archived: "var(--text-secondary)",
|
|
};
|
|
|
|
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
|
|
|
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;
|
|
globalPaused?: boolean;
|
|
onUpdateTask?: (
|
|
id: string,
|
|
updates: { title?: string; description?: string; dependencies?: string[] }
|
|
) => Promise<Task>;
|
|
onArchiveTask?: (id: string) => Promise<Task>;
|
|
onUnarchiveTask?: (id: string) => Promise<Task>;
|
|
onOpenFilesForTask?: (taskId: string) => void;
|
|
}
|
|
|
|
function areTaskBadgeInfosEqual(
|
|
previous: PrInfo | IssueInfo | undefined,
|
|
next: PrInfo | IssueInfo | undefined,
|
|
): boolean {
|
|
if (!previous && !next) return true;
|
|
if (!previous || !next) return false;
|
|
|
|
const previousKeys = Object.keys(previous) as Array<keyof typeof previous>;
|
|
const nextKeys = Object.keys(next) as Array<keyof typeof next>;
|
|
|
|
if (previousKeys.length !== nextKeys.length) return false;
|
|
|
|
return previousKeys.every((key) => previous[key] === next[key]);
|
|
}
|
|
|
|
function areTaskStepsEqual(previous: Task["steps"], next: Task["steps"]): boolean {
|
|
if (previous.length !== next.length) return false;
|
|
return previous.every((step, index) => step.name === next[index]?.name && step.status === next[index]?.status);
|
|
}
|
|
|
|
function areTaskDependenciesEqual(previous: string[], next: string[]): boolean {
|
|
if (previous.length !== next.length) return false;
|
|
return previous.every((dependency, index) => dependency === next[index]);
|
|
}
|
|
|
|
// Keep this comparator aligned with the fields TaskCard renders directly and the
|
|
// task metadata that influences child badge freshness/subscriptions.
|
|
function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): boolean {
|
|
const previousTask = previous.task;
|
|
const nextTask = next.task;
|
|
|
|
return (
|
|
previous.queued === next.queued &&
|
|
previous.globalPaused === next.globalPaused &&
|
|
previous.onOpenDetail === next.onOpenDetail &&
|
|
previous.addToast === next.addToast &&
|
|
previous.onUpdateTask === next.onUpdateTask &&
|
|
previous.onArchiveTask === next.onArchiveTask &&
|
|
previous.onUnarchiveTask === next.onUnarchiveTask &&
|
|
previous.onOpenFilesForTask === next.onOpenFilesForTask &&
|
|
previousTask.id === nextTask.id &&
|
|
previousTask.title === nextTask.title &&
|
|
previousTask.description === nextTask.description &&
|
|
previousTask.column === nextTask.column &&
|
|
previousTask.columnMovedAt === nextTask.columnMovedAt &&
|
|
previousTask.updatedAt === nextTask.updatedAt &&
|
|
previousTask.createdAt === nextTask.createdAt &&
|
|
previousTask.status === nextTask.status &&
|
|
previousTask.paused === nextTask.paused &&
|
|
previousTask.error === nextTask.error &&
|
|
previousTask.size === nextTask.size &&
|
|
previousTask.blockedBy === nextTask.blockedBy &&
|
|
previousTask.worktree === nextTask.worktree &&
|
|
previousTask.baseBranch === nextTask.baseBranch &&
|
|
previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks &&
|
|
previousTask.currentStep === nextTask.currentStep &&
|
|
previousTask.modelProvider === nextTask.modelProvider &&
|
|
previousTask.modelId === nextTask.modelId &&
|
|
previousTask.validatorModelProvider === nextTask.validatorModelProvider &&
|
|
previousTask.validatorModelId === nextTask.validatorModelId &&
|
|
previousTask.reviewLevel === nextTask.reviewLevel &&
|
|
previousTask.mergeRetries === nextTask.mergeRetries &&
|
|
JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) &&
|
|
JSON.stringify(previousTask.steeringComments ?? []) === JSON.stringify(nextTask.steeringComments ?? []) &&
|
|
areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) &&
|
|
areTaskStepsEqual(previousTask.steps, nextTask.steps) &&
|
|
areTaskBadgeInfosEqual(previousTask.prInfo, nextTask.prInfo) &&
|
|
areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo)
|
|
);
|
|
}
|
|
|
|
function TaskCardComponent({
|
|
task,
|
|
queued,
|
|
onOpenDetail,
|
|
addToast,
|
|
globalPaused,
|
|
onUpdateTask,
|
|
onArchiveTask,
|
|
onUnarchiveTask,
|
|
onOpenFilesForTask,
|
|
}: TaskCardProps) {
|
|
const [dragging, setDragging] = useState(false);
|
|
const [fileDragOver, setFileDragOver] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editTitle, setEditTitle] = useState(task.title || "");
|
|
const [editDescription, setEditDescription] = useState(task.description || "");
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [showSteps, setShowSteps] = useState(task.column === "in-progress");
|
|
|
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
|
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const touchOpenHandledRef = useRef(false);
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
const [isInViewport, setIsInViewport] = useState(false);
|
|
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
|
|
|
// Touch gesture detection refs
|
|
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
|
const hasTouchMovedRef = useRef(false);
|
|
|
|
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
return !!target.closest("button, a, input, textarea, select, label, [role='button']");
|
|
}, []);
|
|
|
|
// Reset edit state when task changes
|
|
useEffect(() => {
|
|
setEditTitle(task.title || "");
|
|
setEditDescription(task.description || "");
|
|
}, [task.id, task.title, task.description]);
|
|
|
|
// Auto-focus on title when entering edit mode
|
|
useEffect(() => {
|
|
if (isEditing) {
|
|
titleInputRef.current?.focus();
|
|
titleInputRef.current?.select();
|
|
}
|
|
}, [isEditing]);
|
|
|
|
useEffect(() => {
|
|
if (typeof IntersectionObserver === "undefined") {
|
|
setIsInViewport(true);
|
|
return;
|
|
}
|
|
|
|
const element = cardRef.current;
|
|
if (!element) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
setIsInViewport(entry?.isIntersecting ?? true);
|
|
},
|
|
{ rootMargin: "200px" },
|
|
);
|
|
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, [isEditing, task.id]);
|
|
|
|
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 () => {
|
|
if (isEditing) return; // Don't open detail when editing
|
|
try {
|
|
const detail = await fetchTaskDetail(task.id);
|
|
onOpenDetail(detail);
|
|
} catch {
|
|
addToast("Failed to load task details", "error");
|
|
}
|
|
}, [task.id, onOpenDetail, addToast, isEditing]);
|
|
|
|
const handleCardClick = useCallback((e: React.MouseEvent) => {
|
|
if (touchOpenHandledRef.current) {
|
|
touchOpenHandledRef.current = false;
|
|
return;
|
|
}
|
|
if (isInteractiveTarget(e.target)) return;
|
|
void handleClick();
|
|
}, [handleClick, isInteractiveTarget]);
|
|
|
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
if (!touch) return;
|
|
|
|
touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
|
|
hasTouchMovedRef.current = false;
|
|
}, []);
|
|
|
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
if (!touchStartPosRef.current) return;
|
|
|
|
const touch = e.touches[0];
|
|
if (!touch) return;
|
|
|
|
const dx = Math.abs(touch.clientX - touchStartPosRef.current.x);
|
|
const dy = Math.abs(touch.clientY - touchStartPosRef.current.y);
|
|
|
|
// If moved beyond threshold, mark as moved (scrolling/dragging)
|
|
if (dx > TOUCH_MOVE_THRESHOLD || dy > TOUCH_MOVE_THRESHOLD) {
|
|
hasTouchMovedRef.current = true;
|
|
}
|
|
}, []);
|
|
|
|
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
|
if (isInteractiveTarget(e.target)) return;
|
|
|
|
// Check if this was a valid tap (not a scroll)
|
|
if (!touchStartPosRef.current) return;
|
|
|
|
const touchDuration = Date.now() - touchStartPosRef.current.time;
|
|
const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION;
|
|
const isStationary = !hasTouchMovedRef.current;
|
|
|
|
// Only open modal for quick taps that didn't move significantly
|
|
if (isQuickTap && isStationary) {
|
|
touchOpenHandledRef.current = true;
|
|
void handleClick();
|
|
}
|
|
|
|
// Reset touch tracking
|
|
touchStartPosRef.current = null;
|
|
hasTouchMovedRef.current = false;
|
|
}, [handleClick, isInteractiveTarget]);
|
|
|
|
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
|
|
e.stopPropagation(); // Prevent card click
|
|
try {
|
|
const detail = await fetchTaskDetail(depId);
|
|
onOpenDetail(detail);
|
|
} catch (err: any) {
|
|
addToast(`Failed to load dependency ${depId}`, "error");
|
|
}
|
|
}, [onOpenDetail, addToast]);
|
|
|
|
const isFailed = task.status === "failed";
|
|
const isPaused = task.paused === true;
|
|
const isArchived = task.column === "archived";
|
|
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
|
|
const isDraggable = !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit or if archived
|
|
|
|
// Check if this card can be edited inline
|
|
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
|
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
|
|
|
|
useEffect(() => {
|
|
if (!hasGitHubBadge || !isInViewport) {
|
|
unsubscribeFromBadge(task.id);
|
|
return;
|
|
}
|
|
|
|
subscribeToBadge(task.id);
|
|
return () => {
|
|
unsubscribeFromBadge(task.id);
|
|
};
|
|
}, [hasGitHubBadge, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]);
|
|
|
|
const liveBadgeData = badgeUpdates.get(task.id);
|
|
const { files: sessionFiles, loading: sessionFilesLoading } = useSessionFiles(task.id, task.worktree, task.column);
|
|
|
|
// Get fresh batch data if available
|
|
const batchData = useMemo(() => getFreshBatchData(task.id), [task.id]);
|
|
|
|
// Pick the freshest data among WebSocket, batch, and task data
|
|
const livePrInfo = useMemo(() => {
|
|
const wsData = liveBadgeData?.prInfo;
|
|
const wsTimestamp = liveBadgeData?.timestamp;
|
|
const batchInfo = batchData?.result?.prInfo;
|
|
const batchTimestamp = batchData?.timestamp ? new Date(batchData.timestamp).toISOString() : undefined;
|
|
const taskInfo = task.prInfo;
|
|
const taskTimestamp = task.prInfo?.lastCheckedAt ?? task.updatedAt;
|
|
|
|
// Compare all three sources and pick the freshest
|
|
let bestData = pickPreferredBadge<PrInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
|
|
let bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
|
|
|
|
if (batchInfo && batchTimestamp) {
|
|
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
|
|
bestData = batchInfo;
|
|
}
|
|
}
|
|
|
|
return bestData;
|
|
}, [liveBadgeData, batchData, task.prInfo, task.updatedAt]);
|
|
|
|
const liveIssueInfo = useMemo(() => {
|
|
const wsData = liveBadgeData?.issueInfo;
|
|
const wsTimestamp = liveBadgeData?.timestamp;
|
|
const batchInfo = batchData?.result?.issueInfo;
|
|
const batchTimestamp = batchData?.timestamp ? new Date(batchData.timestamp).toISOString() : undefined;
|
|
const taskInfo = task.issueInfo;
|
|
const taskTimestamp = task.issueInfo?.lastCheckedAt ?? task.updatedAt;
|
|
|
|
// Compare all three sources and pick the freshest
|
|
let bestData = pickPreferredBadge<IssueInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
|
|
let bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
|
|
|
|
if (batchInfo && batchTimestamp) {
|
|
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
|
|
bestData = batchInfo;
|
|
}
|
|
}
|
|
|
|
return bestData;
|
|
}, [liveBadgeData, batchData, task.issueInfo, task.updatedAt]);
|
|
|
|
const enterEditMode = useCallback((e?: React.MouseEvent) => {
|
|
e?.stopPropagation();
|
|
if (!canEdit || isSaving) return;
|
|
setIsEditing(true);
|
|
setEditTitle(task.title || "");
|
|
setEditDescription(task.description || "");
|
|
}, [canEdit, isSaving, task.title, task.description]);
|
|
|
|
const exitEditMode = useCallback(() => {
|
|
setIsEditing(false);
|
|
setEditTitle(task.title || "");
|
|
setEditDescription(task.description || "");
|
|
}, [task.title, task.description]);
|
|
|
|
const hasChanges = useCallback(() => {
|
|
return editTitle !== (task.title || "") || editDescription !== (task.description || "");
|
|
}, [editTitle, editDescription, task.title, task.description]);
|
|
|
|
const saveChanges = useCallback(async () => {
|
|
if (!onUpdateTask || isSaving) return;
|
|
if (!hasChanges()) {
|
|
exitEditMode();
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
await onUpdateTask(task.id, {
|
|
title: editTitle.trim() || undefined,
|
|
description: editDescription.trim() || undefined,
|
|
});
|
|
addToast(`Updated ${task.id}`, "success");
|
|
setIsEditing(false);
|
|
} catch (err: any) {
|
|
addToast(`Failed to update ${task.id}: ${err.message}`, "error");
|
|
// Stay in edit mode on error so user can retry
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [onUpdateTask, task.id, editTitle, editDescription, isSaving, hasChanges, exitEditMode, addToast]);
|
|
|
|
const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
// Move focus to description textarea
|
|
descTextareaRef.current?.focus();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
exitEditMode();
|
|
}
|
|
}, [exitEditMode]);
|
|
|
|
const handleDescKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void saveChanges();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
exitEditMode();
|
|
}
|
|
}, [saveChanges, exitEditMode]);
|
|
|
|
const handleBlur = useCallback(() => {
|
|
// Small delay to allow focus to move between title and description inputs
|
|
// before checking if we should save or cancel
|
|
setTimeout(() => {
|
|
const activeElement = document.activeElement;
|
|
const isFocusInEditArea =
|
|
activeElement === titleInputRef.current ||
|
|
activeElement === descTextareaRef.current ||
|
|
activeElement?.closest(".card-editing-content");
|
|
|
|
if (!isFocusInEditArea) {
|
|
if (hasChanges()) {
|
|
void saveChanges();
|
|
} else {
|
|
exitEditMode();
|
|
}
|
|
}
|
|
}, 0);
|
|
}, [hasChanges, saveChanges, exitEditMode]);
|
|
|
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
|
if (canEdit) {
|
|
e.stopPropagation();
|
|
enterEditMode(e);
|
|
}
|
|
}, [canEdit, enterEditMode]);
|
|
|
|
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
enterEditMode(e);
|
|
}, [enterEditMode]);
|
|
|
|
// Auto-resize textarea (similar to InlineCreateCard)
|
|
const handleDescChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setEditDescription(e.target.value);
|
|
const el = e.target;
|
|
el.style.height = "auto";
|
|
el.style.height = el.scrollHeight + "px";
|
|
}, []);
|
|
|
|
const handleArchiveClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.stopPropagation();
|
|
if (!onArchiveTask) return;
|
|
|
|
void onArchiveTask(task.id).then(() => {
|
|
addToast(`Archived ${task.id}`, "success");
|
|
}).catch((err: any) => {
|
|
addToast(`Failed to archive ${task.id}: ${err.message}`, "error");
|
|
});
|
|
}, [addToast, onArchiveTask, task.id]);
|
|
|
|
const handleUnarchiveClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.stopPropagation();
|
|
if (!onUnarchiveTask) return;
|
|
|
|
void onUnarchiveTask(task.id).then(() => {
|
|
addToast(`Unarchived ${task.id}`, "success");
|
|
}).catch((err: any) => {
|
|
addToast(`Failed to unarchive ${task.id}: ${err.message}`, "error");
|
|
});
|
|
}, [addToast, onUnarchiveTask, task.id]);
|
|
|
|
const handleToggleSteps = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.stopPropagation();
|
|
setShowSteps((current) => !current);
|
|
}, []);
|
|
|
|
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className={cardClass}
|
|
data-id={task.id}
|
|
onDoubleClick={handleDoubleClick}
|
|
>
|
|
<div className="card-editing-content">
|
|
<input
|
|
ref={titleInputRef}
|
|
type="text"
|
|
className="card-edit-title-input"
|
|
placeholder="Task title (optional)"
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
onKeyDown={handleTitleKeyDown}
|
|
onBlur={handleBlur}
|
|
disabled={isSaving}
|
|
/>
|
|
<textarea
|
|
ref={descTextareaRef}
|
|
className="card-edit-desc-textarea"
|
|
placeholder="Task description"
|
|
value={editDescription}
|
|
onChange={handleDescChange}
|
|
onKeyDown={handleDescKeyDown}
|
|
onBlur={handleBlur}
|
|
disabled={isSaving}
|
|
rows={1}
|
|
/>
|
|
{isSaving && (
|
|
<div className="card-edit-loading">
|
|
<span className="card-edit-loading-spinner" />
|
|
<span className="card-edit-loading-text">Saving...</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className={cardClass}
|
|
data-id={task.id}
|
|
draggable={isDraggable}
|
|
onDragStart={isDraggable ? handleDragStart : undefined}
|
|
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
|
onDragOver={handleFileDragOver}
|
|
onDragLeave={handleFileDragLeave}
|
|
onDrop={handleFileDrop}
|
|
onClick={handleCardClick}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
onDoubleClick={handleDoubleClick}
|
|
>
|
|
<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>
|
|
)}
|
|
{hasGitHubBadge && (
|
|
<GitHubBadge
|
|
prInfo={livePrInfo}
|
|
issueInfo={liveIssueInfo}
|
|
/>
|
|
)}
|
|
<div className="card-header-actions">
|
|
{canEdit && (
|
|
<button
|
|
className="card-edit-btn"
|
|
onClick={handleEditClick}
|
|
title="Edit task"
|
|
aria-label="Edit task"
|
|
>
|
|
<Pencil size={12} />
|
|
</button>
|
|
)}
|
|
{task.column === "done" && onArchiveTask && (
|
|
<button
|
|
className="card-archive-btn"
|
|
onClick={handleArchiveClick}
|
|
title="Archive task"
|
|
aria-label="Archive task"
|
|
>
|
|
Archive
|
|
</button>
|
|
)}
|
|
{task.column === "archived" && onUnarchiveTask && (
|
|
<button
|
|
className="card-unarchive-btn"
|
|
onClick={handleUnarchiveClick}
|
|
title="Unarchive task"
|
|
aria-label="Unarchive task"
|
|
>
|
|
Unarchive
|
|
</button>
|
|
)}
|
|
{task.size && (
|
|
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
|
|
{task.size}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isFailed && task.error && (
|
|
<div className="card-error" title={task.error}>
|
|
<span className="card-error-icon">⚠</span>
|
|
<span className="card-error-text">{task.error.length > 60 ? task.error.slice(0, 60) + "…" : task.error}</span>
|
|
</div>
|
|
)}
|
|
<div className="card-title">
|
|
{task.title || task.description || task.id}
|
|
</div>
|
|
{task.steps.length > 0 && (() => {
|
|
const completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").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>
|
|
<button
|
|
type="button"
|
|
className="card-steps-toggle"
|
|
onClick={handleToggleSteps}
|
|
aria-expanded={showSteps}
|
|
aria-label={showSteps ? "Hide steps" : "Show steps"}
|
|
>
|
|
<span>{totalSteps} steps</span>
|
|
<ChevronDown
|
|
size={14}
|
|
className={`card-steps-toggle-icon${showSteps ? " expanded" : ""}`}
|
|
/>
|
|
</button>
|
|
{showSteps && (
|
|
<div className="card-steps-list">
|
|
{task.steps.map((step, index) => (
|
|
<div key={index} className="card-step-item">
|
|
<span
|
|
className={`card-step-dot card-step-dot--${step.status}`}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className={`card-step-name${step.status === "done" ? " completed" : ""}`}>
|
|
{step.name}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
{task.worktree && (task.column === "in-progress" || task.column === "in-review") && (
|
|
<button
|
|
type="button"
|
|
className="card-session-files"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenFilesForTask?.(task.id);
|
|
}}
|
|
disabled={!onOpenFilesForTask}
|
|
>
|
|
<Folder size={12} />
|
|
<span>
|
|
{sessionFilesLoading ? "Checking files…" : `${sessionFiles.length} files changed`}
|
|
</span>
|
|
</button>
|
|
)}
|
|
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
|
|
<div className="card-meta">
|
|
{task.dependencies && task.dependencies.length > 0 && (
|
|
<div className="card-dep-list">
|
|
{task.dependencies.map((depId) => (
|
|
<span
|
|
key={depId}
|
|
className="card-dep-badge clickable"
|
|
onClick={(e) => void handleDepClick(e, depId)}
|
|
title={`Click to view ${depId}`}
|
|
>
|
|
<Link size={12} style={{ verticalAlign: "middle" }} /> {depId}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{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>
|
|
);
|
|
}
|
|
|
|
const TOUCH_MOVE_THRESHOLD = 10; // pixels
|
|
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
|
|
|
|
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
|
|
TaskCard.displayName = "TaskCard";
|