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>
|
||||
|
||||
Reference in New Issue
Block a user