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:
gsxdsm
2026-03-30 11:26:55 -07:00
parent 753bec19df
commit 50cd478f2e
21 changed files with 1269 additions and 276 deletions

View File

@@ -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>

View File

@@ -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";

View File

@@ -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";

View 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";

View File

@@ -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";

View File

@@ -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({

View File

@@ -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")];

View File

@@ -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}
/>
);