Files
fusion/packages/dashboard/app/components/TaskCard.tsx
gsxdsm b4631032f6 feat(KB-149): add mobile touch gesture support to TaskCard
- Implement touch gesture detection for swipe and tap interactions in TaskCard
- Add comprehensive tests for touch event handling (209 lines of test coverage)
- Include changeset for mobile touch sensitivity fix
- Enable smooth card interactions on mobile devices
2026-03-30 11:40:09 -07:00

656 lines
23 KiB
TypeScript

import { memo, useCallback, useState, useRef, useEffect } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@kb/core";
import { fetchTaskDetail, uploadAttachment } from "../api";
import { TaskCardBadge } from "./TaskCardBadge";
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>;
}
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 &&
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,
}: 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(false);
const titleInputRef = useRef<HTMLInputElement>(null);
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
const touchOpenHandledRef = useRef(false);
const cardRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false);
// 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);
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>
)}
{task.size && (
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
{task.size}
</span>
)}
{hasGitHubBadge && (
<TaskCardBadge
taskId={task.id}
prInfo={task.prInfo}
issueInfo={task.issueInfo}
updatedAt={task.updatedAt}
isInViewport={isInViewport}
/>
)}
{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>
)}
</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.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : 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.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";