Files
fusion/packages/dashboard/app/components/TaskCard.tsx
gsxdsm 89b2899935 feat(KB-063): add realtime GitHub badge updates
- Add a focused GitHub badge poller with shared rate-limit, freshness, and cleanup handling
- Wire a dedicated badge websocket server and routes to stream PR and issue badge snapshots
- Add a shared useBadgeWebSocket hook and update TaskCard to subscribe only when visible while preserving partial badge state
- Cover polling, websocket, and task card flows with tests and document the realtime badge channel
2026-03-30 03:19:51 -07:00

572 lines
19 KiB
TypeScript

import { 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 { GitHubBadge } from "./GitHubBadge";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
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"]);
function pickPreferredBadge<T extends { lastCheckedAt?: string }>(
liveValue: T | null | undefined,
liveTimestamp: string | undefined,
taskValue: T | undefined,
taskTimestamp: string | undefined,
): T | undefined {
if (liveValue === undefined || !liveTimestamp) {
return taskValue;
}
if (!taskTimestamp || liveTimestamp >= taskTimestamp) {
return liveValue ?? undefined;
}
return taskValue;
}
interface TaskCardProps {
task: Task;
queued?: boolean;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
globalPaused?: boolean;
tasks?: Task[]; // All tasks for dependency lookup
onUpdateTask?: (
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
}
export function TaskCard({
task,
queued,
onOpenDetail,
addToast,
globalPaused,
tasks = [],
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);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
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 (err: any) {
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;
handleClick();
}, [handleClick, isInteractiveTarget]);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (isInteractiveTarget(e.target)) return;
touchOpenHandledRef.current = true;
handleClick();
}, [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 livePrInfo = pickPreferredBadge<PrInfo>(
liveBadgeData?.prInfo,
liveBadgeData?.timestamp,
task.prInfo,
task.prInfo?.lastCheckedAt ?? task.updatedAt,
);
const liveIssueInfo = pickPreferredBadge<IssueInfo>(
liveBadgeData?.issueInfo,
liveBadgeData?.timestamp,
task.issueInfo,
task.issueInfo?.lastCheckedAt ?? 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();
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()) {
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 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}
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>
)}
{/* Size Indicator */}
{task.size && (
<span className={`card-size-badge size-${task.size.toLowerCase()}`}>
{task.size}
</span>
)}
{/* GitHub badges only for tasks explicitly linked to an issue or PR */}
{(livePrInfo || liveIssueInfo) && (
<GitHubBadge prInfo={livePrInfo} issueInfo={liveIssueInfo} />
)}
{/* Edit button - visible on hover for editable cards */}
{canEdit && (
<button
className="card-edit-btn"
onClick={handleEditClick}
title="Edit task"
aria-label="Edit task"
>
<Pencil size={12} />
</button>
)}
{/* Archive button for done column tasks */}
{task.column === "done" && onArchiveTask && (
<button
className="card-archive-btn"
onClick={(e) => {
e.stopPropagation();
onArchiveTask(task.id).then(() => {
addToast(`Archived ${task.id}`, "success");
}).catch((err: any) => {
addToast(`Failed to archive ${task.id}: ${err.message}`, "error");
});
}}
title="Archive task"
aria-label="Archive task"
>
Archive
</button>
)}
{/* Unarchive button for archived column tasks */}
{task.column === "archived" && onUnarchiveTask && (
<button
className="card-unarchive-btn"
onClick={(e) => {
e.stopPropagation();
onUnarchiveTask(task.id).then(() => {
addToast(`Unarchived ${task.id}`, "success");
}).catch((err: any) => {
addToast(`Failed to unarchive ${task.id}: ${err.message}`, "error");
});
}}
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={(e) => {
e.stopPropagation();
setShowSteps(!showSteps);
}}
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) => 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>
);
}