feat(KB-129): dashboard performance optimizations
- Fix SSE hook cleanup to prevent memory leaks and stale connections - Cap agent log memory and optimize batch log processing - Memoize Board, Column, and TaskCard with custom comparator to reduce re-renders - Stabilize column task arrays and preserve pagination across live updates - Add TaskCardBadge component for PR/issue state display - Remove deprecated GitHub polling code and archive functionality from core store
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@kb/core";
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@kb/core";
|
||||
import { COLUMNS } from "@kb/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
|
||||
interface BoardProps {
|
||||
tasks: Task[];
|
||||
@@ -24,8 +24,36 @@ interface BoardProps {
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
function sortTasksForColumn(tasks: Task[]): Task[] {
|
||||
return [...tasks].sort((a, b) => {
|
||||
if (a.columnMovedAt && b.columnMovedAt) {
|
||||
return b.columnMovedAt.localeCompare(a.columnMovedAt);
|
||||
}
|
||||
if (a.columnMovedAt && !b.columnMovedAt) return -1;
|
||||
if (!a.columnMovedAt && b.columnMovedAt) return 1;
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
});
|
||||
}
|
||||
|
||||
function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
|
||||
if (previous.length !== next.length) return false;
|
||||
return previous.every((task, index) => task === next[index]);
|
||||
}
|
||||
|
||||
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, searchQuery = "" }: BoardProps) {
|
||||
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
||||
triage: [],
|
||||
todo: [],
|
||||
"in-progress": [],
|
||||
"in-review": [],
|
||||
done: [],
|
||||
archived: [],
|
||||
});
|
||||
|
||||
const handleToggleArchivedCollapse = useCallback(() => {
|
||||
setArchivedCollapsed((current) => !current);
|
||||
}, []);
|
||||
|
||||
// Filter tasks based on search query (matches id, title, or description)
|
||||
const filteredTasks = useMemo(() => {
|
||||
@@ -39,25 +67,38 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
);
|
||||
}, [tasks, searchQuery]);
|
||||
|
||||
// Keep per-column array identities stable for unchanged columns so React.memo(Column)
|
||||
// can skip sibling rerenders during unrelated task updates.
|
||||
const tasksByColumn = useMemo(() => {
|
||||
const nextGrouped = Object.fromEntries(
|
||||
COLUMNS.map((column) => [column, [] as Task[]]),
|
||||
) as Record<ColumnType, Task[]>;
|
||||
|
||||
for (const task of filteredTasks) {
|
||||
nextGrouped[task.column].push(task);
|
||||
}
|
||||
|
||||
const previousGrouped = tasksByColumnCacheRef.current;
|
||||
const stableGrouped = {} as Record<ColumnType, Task[]>;
|
||||
|
||||
for (const column of COLUMNS) {
|
||||
const sortedTasks = sortTasksForColumn(nextGrouped[column]);
|
||||
stableGrouped[column] = areTaskArraysEqual(previousGrouped[column], sortedTasks)
|
||||
? previousGrouped[column]
|
||||
: sortedTasks;
|
||||
}
|
||||
|
||||
tasksByColumnCacheRef.current = stableGrouped;
|
||||
return stableGrouped;
|
||||
}, [filteredTasks]);
|
||||
|
||||
return (
|
||||
<main className="board" id="board">
|
||||
{COLUMNS.map((col) => (
|
||||
<Column
|
||||
key={col}
|
||||
column={col}
|
||||
tasks={filteredTasks
|
||||
.filter((t) => t.column === col)
|
||||
.sort((a, b) => {
|
||||
// Tasks with columnMovedAt sort descending (most recent first)
|
||||
// Tasks without it (legacy) fall to the bottom, sorted by createdAt ascending
|
||||
if (a.columnMovedAt && b.columnMovedAt) {
|
||||
return b.columnMovedAt.localeCompare(a.columnMovedAt);
|
||||
}
|
||||
if (a.columnMovedAt && !b.columnMovedAt) return -1;
|
||||
if (!a.columnMovedAt && b.columnMovedAt) return 1;
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
})}
|
||||
allTasks={tasks}
|
||||
tasks={tasksByColumn[col]}
|
||||
maxConcurrent={maxConcurrent}
|
||||
onMoveTask={onMoveTask}
|
||||
onOpenDetail={onOpenDetail}
|
||||
@@ -68,7 +109,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: () => setArchivedCollapsed(!archivedCollapsed) } : {})}
|
||||
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { memo, useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
||||
import type { Task, TaskDetail, TaskCreateInput, Column as ColumnType } from "@kb/core";
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@kb/core";
|
||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@kb/core";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { WorktreeGroup } from "./WorktreeGroup";
|
||||
@@ -9,10 +9,13 @@ import { groupByWorktree } from "../utils/worktreeGrouping";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
const PAGINATED_COLUMN_THRESHOLD = 100;
|
||||
const VISIBLE_TASKS_INITIAL = 50;
|
||||
const VISIBLE_TASKS_INCREMENT = 25;
|
||||
|
||||
interface ColumnProps {
|
||||
column: ColumnType;
|
||||
tasks: Task[];
|
||||
allTasks: Task[];
|
||||
maxConcurrent: number;
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
@@ -32,13 +35,25 @@ interface ColumnProps {
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
|
||||
// Archived column is collapsed by default - don't show drag state when collapsed
|
||||
const isArchived = column === "archived";
|
||||
const isCollapsed = isArchived && collapsed;
|
||||
const shouldPaginate = !isArchived && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD;
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleTaskCount((current) => {
|
||||
if (column === "in-progress" || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) {
|
||||
return VISIBLE_TASKS_INITIAL;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length);
|
||||
});
|
||||
}, [column, isArchived, tasks.length]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
// Don't allow dropping into archived column via drag-drop
|
||||
@@ -68,6 +83,22 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
}
|
||||
}, [column, onMoveTask, addToast]);
|
||||
|
||||
const worktreeGroups = useMemo(() => {
|
||||
if (column !== "in-progress") return [];
|
||||
return groupByWorktree(tasks, tasks, maxConcurrent);
|
||||
}, [column, tasks, maxConcurrent]);
|
||||
|
||||
const visibleTasks = useMemo(() => {
|
||||
if (!shouldPaginate) return tasks;
|
||||
return tasks.slice(0, visibleTaskCount);
|
||||
}, [shouldPaginate, tasks, visibleTaskCount]);
|
||||
|
||||
const hiddenTaskCount = Math.max(0, tasks.length - visibleTasks.length);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length));
|
||||
}, [tasks.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`column${dragOver ? " drag-over" : ""}${isArchived ? " column-archived" : ""}${isCollapsed ? " column-collapsed" : ""}`}
|
||||
@@ -114,45 +145,54 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
|
||||
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
(() => {
|
||||
const groups = groupByWorktree(tasks, allTasks, maxConcurrent);
|
||||
return groups.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<WorktreeGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
activeTasks={group.activeTasks}
|
||||
queuedTasks={group.queuedTasks}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
);
|
||||
})()
|
||||
worktreeGroups.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
worktreeGroups.map((group) => (
|
||||
<WorktreeGroup
|
||||
key={group.label}
|
||||
label={group.label}
|
||||
activeTasks={group.activeTasks}
|
||||
queuedTasks={group.queuedTasks}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))
|
||||
)
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-column">No tasks</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={allTasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{visibleTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
/>
|
||||
))}
|
||||
{shouldPaginate && hiddenTaskCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Load {Math.min(VISIBLE_TASKS_INCREMENT, hiddenTaskCount)} more ({hiddenTaskCount} remaining)
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Column = memo(ColumnComponent);
|
||||
Column.displayName = "Column";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
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 { GitHubBadge } from "./GitHubBadge";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
import { TaskCardBadge } from "./TaskCardBadge";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
@@ -28,30 +27,12 @@ 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[] }
|
||||
@@ -60,13 +41,82 @@ interface TaskCardProps {
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
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,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
onArchiveTask,
|
||||
onUnarchiveTask,
|
||||
@@ -84,7 +134,6 @@ export function TaskCard({
|
||||
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;
|
||||
@@ -176,7 +225,7 @@ export function TaskCard({
|
||||
try {
|
||||
const detail = await fetchTaskDetail(task.id);
|
||||
onOpenDetail(detail);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
addToast("Failed to load task details", "error");
|
||||
}
|
||||
}, [task.id, onOpenDetail, addToast, isEditing]);
|
||||
@@ -187,13 +236,13 @@ export function TaskCard({
|
||||
return;
|
||||
}
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
handleClick();
|
||||
void handleClick();
|
||||
}, [handleClick, isInteractiveTarget]);
|
||||
|
||||
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||
if (isInteractiveTarget(e.target)) return;
|
||||
touchOpenHandledRef.current = true;
|
||||
handleClick();
|
||||
void handleClick();
|
||||
}, [handleClick, isInteractiveTarget]);
|
||||
|
||||
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
|
||||
@@ -216,32 +265,6 @@ export function TaskCard({
|
||||
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;
|
||||
@@ -297,7 +320,7 @@ export function TaskCard({
|
||||
const handleDescKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
saveChanges();
|
||||
void saveChanges();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEditMode();
|
||||
@@ -316,7 +339,7 @@ export function TaskCard({
|
||||
|
||||
if (!isFocusInEditArea) {
|
||||
if (hasChanges()) {
|
||||
saveChanges();
|
||||
void saveChanges();
|
||||
} else {
|
||||
exitEditMode();
|
||||
}
|
||||
@@ -344,6 +367,33 @@ export function TaskCard({
|
||||
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) {
|
||||
@@ -424,17 +474,20 @@ export function TaskCard({
|
||||
{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} />
|
||||
{hasGitHubBadge && (
|
||||
<TaskCardBadge
|
||||
taskId={task.id}
|
||||
prInfo={task.prInfo}
|
||||
issueInfo={task.issueInfo}
|
||||
updatedAt={task.updatedAt}
|
||||
isInViewport={isInViewport}
|
||||
/>
|
||||
)}
|
||||
{/* Edit button - visible on hover for editable cards */}
|
||||
{canEdit && (
|
||||
<button
|
||||
className="card-edit-btn"
|
||||
@@ -445,36 +498,20 @@ export function TaskCard({
|
||||
<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");
|
||||
});
|
||||
}}
|
||||
onClick={handleArchiveClick}
|
||||
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");
|
||||
});
|
||||
}}
|
||||
onClick={handleUnarchiveClick}
|
||||
title="Unarchive task"
|
||||
aria-label="Unarchive task"
|
||||
>
|
||||
@@ -492,7 +529,7 @@ export function TaskCard({
|
||||
{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 completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
const totalSteps = task.steps.length;
|
||||
return (
|
||||
<>
|
||||
@@ -511,10 +548,7 @@ export function TaskCard({
|
||||
<button
|
||||
type="button"
|
||||
className="card-steps-toggle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSteps(!showSteps);
|
||||
}}
|
||||
onClick={handleToggleSteps}
|
||||
aria-expanded={showSteps}
|
||||
aria-label={showSteps ? "Hide steps" : "Show steps"}
|
||||
>
|
||||
@@ -544,28 +578,31 @@ export function TaskCard({
|
||||
})()}
|
||||
{((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.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}
|
||||
<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>}
|
||||
{(queued || task.status === "queued") && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
|
||||
TaskCard.displayName = "TaskCard";
|
||||
|
||||
69
packages/dashboard/app/components/TaskCardBadge.tsx
Normal file
69
packages/dashboard/app/components/TaskCardBadge.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { memo, useEffect } from "react";
|
||||
import type { IssueInfo, PrInfo } from "@kb/core";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
|
||||
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 TaskCardBadgeProps {
|
||||
taskId: string;
|
||||
prInfo?: PrInfo;
|
||||
issueInfo?: IssueInfo;
|
||||
updatedAt: string;
|
||||
isInViewport: boolean;
|
||||
}
|
||||
|
||||
function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, updatedAt, isInViewport }: TaskCardBadgeProps) {
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
|
||||
const hasGitHubBadge = Boolean(prInfo || issueInfo);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasGitHubBadge || !isInViewport) {
|
||||
unsubscribeFromBadge(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
subscribeToBadge(taskId);
|
||||
return () => {
|
||||
unsubscribeFromBadge(taskId);
|
||||
};
|
||||
}, [hasGitHubBadge, isInViewport, subscribeToBadge, taskId, unsubscribeFromBadge]);
|
||||
|
||||
const liveBadgeData = badgeUpdates.get(taskId);
|
||||
const livePrInfo = pickPreferredBadge<PrInfo>(
|
||||
liveBadgeData?.prInfo,
|
||||
liveBadgeData?.timestamp,
|
||||
prInfo,
|
||||
prInfo?.lastCheckedAt ?? updatedAt,
|
||||
);
|
||||
const liveIssueInfo = pickPreferredBadge<IssueInfo>(
|
||||
liveBadgeData?.issueInfo,
|
||||
liveBadgeData?.timestamp,
|
||||
issueInfo,
|
||||
issueInfo?.lastCheckedAt ?? updatedAt,
|
||||
);
|
||||
|
||||
if (!livePrInfo && !liveIssueInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <GitHubBadge prInfo={livePrInfo} issueInfo={liveIssueInfo} />;
|
||||
}
|
||||
|
||||
export const TaskCardBadge = memo(TaskCardBadgeComponent);
|
||||
TaskCardBadge.displayName = "TaskCardBadge";
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
import { ClipboardList, GitBranch } from "lucide-react";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
@@ -10,21 +11,19 @@ interface WorktreeGroupProps {
|
||||
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>;
|
||||
}
|
||||
|
||||
export function WorktreeGroup({
|
||||
function WorktreeGroupComponent({
|
||||
label,
|
||||
activeTasks,
|
||||
queuedTasks,
|
||||
onOpenDetail,
|
||||
addToast,
|
||||
globalPaused,
|
||||
tasks = [],
|
||||
onUpdateTask,
|
||||
}: WorktreeGroupProps) {
|
||||
return (
|
||||
@@ -36,7 +35,7 @@ export function WorktreeGroup({
|
||||
<span className="worktree-label">{label}</span>
|
||||
</div>
|
||||
{activeTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} tasks={tasks} onUpdateTask={onUpdateTask} />
|
||||
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} />
|
||||
))}
|
||||
{queuedTasks.map((task) => (
|
||||
<TaskCard
|
||||
@@ -46,10 +45,12 @@ export function WorktreeGroup({
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
tasks={tasks}
|
||||
onUpdateTask={onUpdateTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const WorktreeGroup = memo(WorktreeGroupComponent);
|
||||
WorktreeGroup.displayName = "WorktreeGroup";
|
||||
|
||||
@@ -1,36 +1,55 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { Board } from "../Board";
|
||||
import { COLUMNS } from "@kb/core";
|
||||
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
const columnRenderCounts: Record<string, number> = {};
|
||||
|
||||
// Mock child components so we only test Board's own rendering
|
||||
vi.mock("../Column", () => ({
|
||||
Column: ({ column, tasks }: { column: string; tasks: Task[] }) => (
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} />
|
||||
),
|
||||
Column: React.memo(({ column, tasks, onToggleCollapse }: { column: string; tasks: Task[]; onToggleCollapse?: () => void }) => {
|
||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||
return (
|
||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)}>
|
||||
{onToggleCollapse && <button onClick={onToggleCollapse}>toggle-{column}</button>}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
const noopAsync = () => Promise.resolve({} as any);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of Object.keys(columnRenderCounts)) {
|
||||
delete columnRenderCounts[key];
|
||||
}
|
||||
});
|
||||
|
||||
function createBoardProps(overrides = {}) {
|
||||
return {
|
||||
tasks: [],
|
||||
maxConcurrent: 2,
|
||||
onMoveTask: noopAsync,
|
||||
onOpenDetail: noop,
|
||||
addToast: noop,
|
||||
onQuickCreate: noopAsync,
|
||||
onNewTask: noop,
|
||||
autoMerge: true,
|
||||
onToggleAutoMerge: noop,
|
||||
globalPaused: false,
|
||||
onUpdateTask: undefined,
|
||||
onArchiveTask: undefined,
|
||||
onUnarchiveTask: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBoard(props = {}) {
|
||||
return render(
|
||||
<Board
|
||||
tasks={[]}
|
||||
maxConcurrent={2}
|
||||
onMoveTask={noopAsync}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
onQuickCreate={noopAsync}
|
||||
onNewTask={noop}
|
||||
autoMerge={true}
|
||||
onToggleAutoMerge={noop}
|
||||
globalPaused={false}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return render(<Board {...createBoardProps(props)} />);
|
||||
}
|
||||
|
||||
describe("Board", () => {
|
||||
@@ -195,6 +214,51 @@ describe("Board", () => {
|
||||
expect(todoTasks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps unaffected columns stable when archived collapse toggles", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "KB-001", description: "Todo task", column: "todo" }),
|
||||
createTask({ id: "KB-002", description: "Archived task", column: "archived" }),
|
||||
];
|
||||
|
||||
renderBoard({ tasks });
|
||||
|
||||
const initialTodoRenders = columnRenderCounts.todo;
|
||||
const initialArchivedRenders = columnRenderCounts.archived;
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "toggle-archived" }));
|
||||
|
||||
expect(columnRenderCounts.archived).toBeGreaterThan(initialArchivedRenders);
|
||||
expect(columnRenderCounts.todo).toBe(initialTodoRenders);
|
||||
});
|
||||
|
||||
it("only re-renders the affected column when a task updates", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({ id: "KB-001", description: "Todo task", column: "todo", title: "Original" }),
|
||||
createTask({ id: "KB-002", description: "Done task", column: "done", title: "Done" }),
|
||||
];
|
||||
|
||||
const { rerender } = renderBoard({ tasks });
|
||||
|
||||
const initialTodoRenders = columnRenderCounts.todo;
|
||||
const initialDoneRenders = columnRenderCounts.done;
|
||||
|
||||
rerender(
|
||||
<Board
|
||||
{...createBoardProps({
|
||||
tasks: [
|
||||
{ ...tasks[0], title: "Updated" },
|
||||
tasks[1],
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]");
|
||||
expect(todoTasks[0].title).toBe("Updated");
|
||||
expect(columnRenderCounts.todo).toBeGreaterThan(initialTodoRenders);
|
||||
expect(columnRenderCounts.done).toBe(initialDoneRenders);
|
||||
});
|
||||
|
||||
it("filtered tasks are sorted correctly (columnMovedAt, createdAt)", () => {
|
||||
const tasks: Task[] = [
|
||||
createTask({
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Column } from "../Column";
|
||||
import type { Task, Column as ColumnType } from "@kb/core";
|
||||
|
||||
// Mock child components to keep tests focused on the Column badge behavior
|
||||
const taskCardRenderSpy = vi.fn();
|
||||
|
||||
vi.mock("../TaskCard", () => ({
|
||||
TaskCard: ({ task }: { task: Task }) => <div data-testid={`task-${task.id}`} />,
|
||||
TaskCard: React.memo(({ task }: { task: Task }) => {
|
||||
taskCardRenderSpy(task.id);
|
||||
return <div data-testid={`task-${task.id}`} />;
|
||||
}),
|
||||
}));
|
||||
vi.mock("../WorktreeGroup", () => ({
|
||||
WorktreeGroup: () => <div />,
|
||||
@@ -34,9 +41,12 @@ function makeTask(id: string): Task {
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
taskCardRenderSpy.mockClear();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
column: "triage" as ColumnType,
|
||||
allTasks: [] as Task[],
|
||||
maxConcurrent: 2,
|
||||
onMoveTask: vi.fn().mockResolvedValue({} as Task),
|
||||
onOpenDetail: vi.fn(),
|
||||
@@ -76,6 +86,100 @@ describe("Column count-flash", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column memoization", () => {
|
||||
it("does not re-render task cards when rerendered with the same task references", () => {
|
||||
const tasks = [makeTask("KB-001")];
|
||||
const props = { ...defaultProps, tasks };
|
||||
|
||||
const { rerender } = render(<Column {...props} />);
|
||||
expect(taskCardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<Column {...props} />);
|
||||
|
||||
expect(taskCardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column pagination", () => {
|
||||
it("shows only the initial page for large non-in-progress columns", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} />);
|
||||
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(50);
|
||||
expect(screen.getByRole("button", { name: /Load 25 more/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads more tasks on demand", async () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Load 25 more/i }));
|
||||
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(75);
|
||||
});
|
||||
|
||||
it("preserves pagination across task array updates", async () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
const { rerender } = render(<Column {...defaultProps} column="todo" tasks={tasks} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Load 25 more/i }));
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(75);
|
||||
|
||||
rerender(<Column {...defaultProps} column="todo" tasks={[...tasks]} />);
|
||||
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(75);
|
||||
});
|
||||
|
||||
it("clamps visible tasks when a paginated list shrinks", async () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
const { rerender } = render(<Column {...defaultProps} column="todo" tasks={tasks} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Load 25 more/i }));
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(75);
|
||||
|
||||
rerender(<Column {...defaultProps} column="todo" tasks={tasks.slice(0, 60)} />);
|
||||
|
||||
expect(screen.getAllByTestId(/task-/)).toHaveLength(60);
|
||||
});
|
||||
|
||||
it("still handles drops when pagination is enabled", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} onMoveTask={onMoveTask} />);
|
||||
|
||||
const column = screen.getByText("110").closest(".column") as HTMLElement;
|
||||
const dataTransfer = {
|
||||
getData: vi.fn().mockReturnValue("KB-999"),
|
||||
dropEffect: "move",
|
||||
};
|
||||
|
||||
fireEvent.drop(column, { dataTransfer });
|
||||
|
||||
expect(onMoveTask).toHaveBeenCalledWith("KB-999", "todo");
|
||||
});
|
||||
|
||||
it("does not paginate at the threshold boundary", () => {
|
||||
const tasks = Array.from({ length: 100 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`));
|
||||
render(<Column {...defaultProps} column="todo" tasks={tasks} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not paginate in-progress columns", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => ({ ...makeTask(`KB-${String(index + 1).padStart(3, "0")}`), column: "in-progress" as ColumnType }));
|
||||
render(<Column {...defaultProps} column="in-progress" tasks={tasks} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not paginate archived columns", () => {
|
||||
const tasks = Array.from({ length: 110 }, (_, index) => ({ ...makeTask(`KB-${String(index + 1).padStart(3, "0")}`), column: "archived" as ColumnType }));
|
||||
render(<Column {...defaultProps} column="archived" tasks={tasks} collapsed={false} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column QuickEntryBox", () => {
|
||||
it("renders QuickEntryBox in triage column when onQuickCreate is provided", () => {
|
||||
const tasks = [makeTask("KB-001")];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Column, Task, TaskDetail } from "@kb/core";
|
||||
import { TaskCard } from "../TaskCard";
|
||||
import React, { useState } from "react";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
@@ -46,6 +47,207 @@ function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?:
|
||||
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
|
||||
}
|
||||
|
||||
describe("TaskCard memoization", () => {
|
||||
const createTask = (overrides: Partial<Task> = {}): Task => ({
|
||||
id: "KB-001",
|
||||
description: "Test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as Task);
|
||||
|
||||
it("does not re-render when parent re-renders with an equivalent task object", async () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const cardRenderSpy = vi.fn();
|
||||
|
||||
function MemoProbe({ task }: { task: Task }) {
|
||||
cardRenderSpy();
|
||||
return <TaskCard task={task} onOpenDetail={onOpenDetail} addToast={addToast} />;
|
||||
}
|
||||
|
||||
const MemoizedProbe = React.memo(MemoProbe);
|
||||
|
||||
function Harness() {
|
||||
const [count, setCount] = useState(0);
|
||||
const task = createTask();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setCount((current) => current + 1)}>rerender {count}</button>
|
||||
<MemoizedProbe task={{ ...task }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /rerender/i }));
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("Test task")).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-renders when a render-relevant task field changes", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const cardRenderSpy = vi.fn();
|
||||
|
||||
function MemoProbe({ task }: { task: Task }) {
|
||||
cardRenderSpy();
|
||||
return <TaskCard task={task} onOpenDetail={onOpenDetail} addToast={addToast} />;
|
||||
}
|
||||
|
||||
const MemoizedProbe = React.memo(MemoProbe);
|
||||
const { rerender } = render(<MemoizedProbe task={createTask({ title: "Original" })} />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MemoizedProbe task={createTask({ title: "Updated" })} />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText("Updated")).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-renders when dependency badges change", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const cardRenderSpy = vi.fn();
|
||||
|
||||
function MemoProbe({ task }: { task: Task }) {
|
||||
cardRenderSpy();
|
||||
return <TaskCard task={task} onOpenDetail={onOpenDetail} addToast={addToast} />;
|
||||
}
|
||||
|
||||
const MemoizedProbe = React.memo(MemoProbe);
|
||||
const { rerender } = render(<MemoizedProbe task={createTask({ dependencies: ["KB-001"] })} />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MemoizedProbe task={createTask({ dependencies: ["KB-001", "KB-002"] })} />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getAllByTitle(/Click to view/)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-renders when PR badge data changes", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const cardRenderSpy = vi.fn();
|
||||
|
||||
function MemoProbe({ task }: { task: Task }) {
|
||||
cardRenderSpy();
|
||||
return <TaskCard task={task} onOpenDetail={onOpenDetail} addToast={addToast} />;
|
||||
}
|
||||
|
||||
const MemoizedProbe = React.memo(MemoProbe);
|
||||
const basePrInfo = {
|
||||
number: 42,
|
||||
url: "https://github.com/example/repo/pull/42",
|
||||
status: "open" as const,
|
||||
title: "Initial PR",
|
||||
headBranch: "kb/kb-129",
|
||||
baseBranch: "main",
|
||||
lastCheckedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
const { rerender } = render(<MemoizedProbe task={createTask({ prInfo: basePrInfo })} />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<MemoizedProbe
|
||||
task={createTask({
|
||||
prInfo: { ...basePrInfo, status: "merged", title: "Merged PR" },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-renders when step progress changes", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const cardRenderSpy = vi.fn();
|
||||
|
||||
function MemoProbe({ task }: { task: Task }) {
|
||||
cardRenderSpy();
|
||||
return <TaskCard task={task} onOpenDetail={onOpenDetail} addToast={addToast} />;
|
||||
}
|
||||
|
||||
const MemoizedProbe = React.memo(MemoProbe);
|
||||
const { rerender } = render(
|
||||
<MemoizedProbe
|
||||
task={createTask({
|
||||
steps: [{ name: "Step 1", status: "pending" }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<MemoizedProbe
|
||||
task={createTask({
|
||||
steps: [{ name: "Step 1", status: "done" }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText("1/1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-renders when blockedBy changes", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const cardRenderSpy = vi.fn();
|
||||
|
||||
function MemoProbe({ task }: { task: Task }) {
|
||||
cardRenderSpy();
|
||||
return <TaskCard task={task} onOpenDetail={onOpenDetail} addToast={addToast} />;
|
||||
}
|
||||
|
||||
const MemoizedProbe = React.memo(MemoProbe);
|
||||
const { rerender } = render(<MemoizedProbe task={createTask()} />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MemoizedProbe task={createTask({ blockedBy: "KB-777" })} />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText("KB-777")).toBeDefined();
|
||||
});
|
||||
|
||||
it("re-renders when queued state changes", () => {
|
||||
const onOpenDetail = vi.fn();
|
||||
const addToast = vi.fn();
|
||||
const cardRenderSpy = vi.fn();
|
||||
|
||||
function MemoProbe({ queued }: { queued?: boolean }) {
|
||||
cardRenderSpy();
|
||||
return <TaskCard task={createTask()} queued={queued} onOpenDetail={onOpenDetail} addToast={addToast} />;
|
||||
}
|
||||
|
||||
const MemoizedProbe = React.memo(MemoProbe);
|
||||
const { rerender } = render(<MemoizedProbe />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<MemoizedProbe queued />);
|
||||
|
||||
expect(cardRenderSpy).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText(/Queued/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard agent-active class", () => {
|
||||
it("applies agent-active for an active status (executing)", () => {
|
||||
const cls = computeCardClass({ status: "executing" });
|
||||
@@ -352,7 +554,6 @@ describe("TaskCard clickable dependencies", () => {
|
||||
task={task}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={noopToast}
|
||||
tasks={allTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -396,7 +597,6 @@ describe("TaskCard clickable dependencies", () => {
|
||||
task={task}
|
||||
onOpenDetail={onOpenDetail}
|
||||
addToast={noopToast}
|
||||
tasks={allTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user