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:
@@ -216,6 +216,16 @@ To add a new color theme:
|
||||
3. Add the swatch class for the theme picker in the CSS
|
||||
4. Update `ThemeSelector.tsx` with the new theme option
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
The dashboard includes several runtime safeguards to stay responsive during long sessions and on larger boards:
|
||||
|
||||
- **Agent log cap**: The UI keeps only the most recent **500 agent log entries per task** in memory. Historical log fetches and live SSE appends are both truncated to this window.
|
||||
- **Memoized task rendering**: `TaskCard`, `Column`, and worktree grouping are memoized so unrelated SSE updates do not force the whole board to repaint. The board also preserves stable per-column task arrays for unchanged columns.
|
||||
- **Large-column pagination**: Columns with more than **100 tasks** use incremental client-side pagination, rendering **50 tasks initially** and loading **25 more** at a time. This is applied to active non-archived, non-`in-progress` columns to avoid breaking worktree grouping and archived browsing behavior.
|
||||
- **Badge update isolation**: Live GitHub PR/issue badge websocket updates are rendered through a dedicated child component so badge freshness is preserved even when task cards are memoized.
|
||||
- **SSE cleanup and reconnects**: Task and log streaming hooks explicitly clean up EventSource listeners/connections and avoid duplicate stream setup during rerenders.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -11,8 +11,12 @@ async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function fetchTasks(): Promise<Task[]> {
|
||||
return api<Task[]>("/tasks");
|
||||
export function fetchTasks(limit?: number, offset?: number): Promise<Task[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (limit !== undefined) search.set("limit", String(limit));
|
||||
if (offset !== undefined) search.set("offset", String(offset));
|
||||
const suffix = search.size > 0 ? `?${search.toString()}` : "";
|
||||
return api<Task[]>(`/tasks${suffix}`);
|
||||
}
|
||||
|
||||
export async function fetchTaskDetail(id: string): Promise<TaskDetail> {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useAgentLogs } from "../useAgentLogs";
|
||||
import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs";
|
||||
import { fetchAgentLogs } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
@@ -16,7 +16,9 @@ class MockEventSource {
|
||||
url: string;
|
||||
listeners: Record<string, ((e: any) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 2;
|
||||
});
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
@@ -135,6 +137,54 @@ describe("useAgentLogs", () => {
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("truncates oversized historical logs to the most recent entries", async () => {
|
||||
const historicalLogs = Array.from({ length: MAX_LOG_ENTRIES + 25 }, (_, index) => ({
|
||||
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "KB-001",
|
||||
text: `entry-${index}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("KB-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].text).toBe("entry-25");
|
||||
expect(result.current.entries.at(-1)?.text).toBe(`entry-${MAX_LOG_ENTRIES + 24}`);
|
||||
});
|
||||
|
||||
it("truncates live SSE entries to the most recent entries", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("KB-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
for (let index = 0; index < MAX_LOG_ENTRIES + 20; index++) {
|
||||
es._emit("agent:log", {
|
||||
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "KB-001",
|
||||
text: `live-${index}`,
|
||||
type: "text",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].text).toBe("live-20");
|
||||
expect(result.current.entries.at(-1)?.text).toBe(`live-${MAX_LOG_ENTRIES + 19}`);
|
||||
});
|
||||
|
||||
it("does not fetch when taskId is null", () => {
|
||||
renderHook(() => useAgentLogs(null, true));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useMultiAgentLogs } from "../useMultiAgentLogs";
|
||||
import { MAX_LOG_ENTRIES, useMultiAgentLogs } from "../useMultiAgentLogs";
|
||||
import { fetchAgentLogs } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
@@ -15,7 +15,9 @@ class MockEventSource {
|
||||
url: string;
|
||||
listeners: Record<string, ((e: { data: string }) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 2;
|
||||
});
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
@@ -27,10 +29,14 @@ class MockEventSource {
|
||||
this.listeners[event].push(fn);
|
||||
}
|
||||
|
||||
removeEventListener(event: string, fn: (e: { data: string }) => void) {
|
||||
this.listeners[event] = (this.listeners[event] || []).filter((listener) => listener !== fn);
|
||||
}
|
||||
|
||||
// Helper to simulate a server event
|
||||
_emit(event: string, data: unknown) {
|
||||
_emit(event: string, data?: unknown) {
|
||||
for (const fn of this.listeners[event] || []) {
|
||||
fn({ data: JSON.stringify(data) });
|
||||
fn(data === undefined ? ({ } as { data: string }) : { data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,8 +317,13 @@ describe("useMultiAgentLogs", () => {
|
||||
expect(result.current["KB-001"].entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("only opens one connection per task ID (no duplicates)", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
it("does not create duplicate connections while historical fetch is still pending", async () => {
|
||||
let resolveFetch: ((value: never[]) => void) | undefined;
|
||||
mockFetchAgentLogs.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
@@ -328,21 +339,152 @@ describe("useMultiAgentLogs", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1);
|
||||
expect(instances.filter((es) => es.url === "/api/tasks/KB-001/logs/stream")).toHaveLength(1);
|
||||
});
|
||||
|
||||
const initialCount = instances.length;
|
||||
|
||||
// Re-render with same task ID (should not create new connection)
|
||||
rerender({ taskIds: ["KB-001"] });
|
||||
|
||||
// Wait a bit
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// In strict mode, we may have more instances due to double-run,
|
||||
// but the active (non-closed) connections should remain stable
|
||||
const activeConnections = instances.filter((es) => !es.close.mock?.calls?.length);
|
||||
expect(activeConnections.length).toBeLessThanOrEqual(initialCount);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(instances.filter((es) => es.url === "/api/tasks/KB-001/logs/stream")).toHaveLength(1);
|
||||
|
||||
resolveFetch?.([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes a task connection when its stream emits an error", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = instances[0];
|
||||
|
||||
act(() => {
|
||||
es._emit("error");
|
||||
});
|
||||
|
||||
expect(es.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("truncates oversized historical logs per task to the most recent entries", async () => {
|
||||
const oversized = Array.from({ length: MAX_LOG_ENTRIES + 10 }, (_, index) => ({
|
||||
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "KB-001",
|
||||
text: `entry-${index}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
|
||||
mockFetchAgentLogs.mockResolvedValue(oversized);
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(MAX_LOG_ENTRIES);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries[0].text).toBe("entry-10");
|
||||
expect(result.current["KB-001"].entries.at(-1)?.text).toBe(`entry-${MAX_LOG_ENTRIES + 9}`);
|
||||
});
|
||||
|
||||
it("preserves streamed entries that arrive before historical fetch resolves", async () => {
|
||||
let resolveFetch: ((value: Array<{ timestamp: string; taskId: string; text: string; type: "text" }>) => void) | undefined;
|
||||
mockFetchAgentLogs.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
instances[0]._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "KB-001",
|
||||
text: "live-before-history",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
resolveFetch?.([
|
||||
{
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
taskId: "KB-001",
|
||||
text: "historical",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries[0].text).toBe("historical");
|
||||
expect(result.current["KB-001"].entries[1].text).toBe("live-before-history");
|
||||
});
|
||||
|
||||
it("truncates live SSE entries per task to the most recent entries", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const instances: MockEventSource[] = [];
|
||||
(globalThis as unknown as Record<string, unknown>).EventSource = class extends MockEventSource {
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
instances.push(this);
|
||||
}
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["KB-001"]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
for (let index = 0; index < MAX_LOG_ENTRIES + 15; index++) {
|
||||
instances[0]._emit("agent:log", {
|
||||
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "KB-001",
|
||||
text: `live-${index}`,
|
||||
type: "text",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current["KB-001"].entries).toHaveLength(MAX_LOG_ENTRIES);
|
||||
});
|
||||
|
||||
expect(result.current["KB-001"].entries[0].text).toBe("live-15");
|
||||
expect(result.current["KB-001"].entries.at(-1)?.text).toBe(`live-${MAX_LOG_ENTRIES + 14}`);
|
||||
});
|
||||
|
||||
it("handles SSE events for multiple tasks independently", async () => {
|
||||
|
||||
@@ -21,10 +21,13 @@ const mockUpdateTask = vi.mocked(api.updateTask);
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
static CLOSED = 2;
|
||||
url: string;
|
||||
listeners: Record<string, ((e: any) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
close = vi.fn(() => {
|
||||
this.readyState = MockEventSource.CLOSED;
|
||||
});
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
@@ -37,10 +40,14 @@ class MockEventSource {
|
||||
this.listeners[event].push(fn);
|
||||
}
|
||||
|
||||
removeEventListener(event: string, fn: (e: any) => void) {
|
||||
this.listeners[event] = (this.listeners[event] || []).filter((listener) => listener !== fn);
|
||||
}
|
||||
|
||||
// Helper to simulate a server event
|
||||
_emit(event: string, data: unknown) {
|
||||
_emit(event: string, data?: unknown) {
|
||||
for (const fn of this.listeners[event] || []) {
|
||||
fn({ data: JSON.stringify(data) });
|
||||
fn(data === undefined ? {} : { data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +181,48 @@ describe("useTasks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the SSE connection on unmount", async () => {
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
unmount();
|
||||
|
||||
expect(es.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes the broken SSE connection and reconnects after an error", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
act(() => {
|
||||
first._emit("error");
|
||||
});
|
||||
|
||||
expect(first.close).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
describe("SSE event: task:updated", () => {
|
||||
it("updates task fields", async () => {
|
||||
const initialTask = createMockTask({
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AgentLogEntry } from "@kb/core";
|
||||
import { fetchAgentLogs } from "../api";
|
||||
|
||||
export const MAX_LOG_ENTRIES = 500;
|
||||
|
||||
function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
return entries.length > MAX_LOG_ENTRIES
|
||||
? entries.slice(-MAX_LOG_ENTRIES)
|
||||
: entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for a task.
|
||||
*
|
||||
@@ -33,9 +41,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
async function init() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const historical = await fetchAgentLogs(taskId!);
|
||||
const historical = await fetchAgentLogs(taskId);
|
||||
if (cancelled) return;
|
||||
setEntries(historical);
|
||||
setEntries(capLogEntries(historical));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setEntries([]);
|
||||
@@ -51,14 +59,14 @@ export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setEntries((prev) => [...prev, entry]);
|
||||
setEntries((prev) => capLogEntries([...prev, entry]));
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -2,6 +2,14 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AgentLogEntry } from "@kb/core";
|
||||
import { fetchAgentLogs } from "../api";
|
||||
|
||||
export const MAX_LOG_ENTRIES = 500;
|
||||
|
||||
function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
return entries.length > MAX_LOG_ENTRIES
|
||||
? entries.slice(-MAX_LOG_ENTRIES)
|
||||
: entries;
|
||||
}
|
||||
|
||||
export interface TaskLogState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
@@ -13,7 +21,6 @@ export type LogStateMap = Record<string, TaskLogState>;
|
||||
interface InitState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
es?: EventSource;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,9 +37,12 @@ interface InitState {
|
||||
export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
// Store state per task
|
||||
const [stateMap, setStateMap] = useState<Record<string, InitState>>({});
|
||||
|
||||
|
||||
// Ref to track active EventSources
|
||||
const sourcesRef = useRef<Record<string, EventSource>>({});
|
||||
const initializingRef = useRef<Set<string>>(new Set());
|
||||
const cancelledRef = useRef<Record<string, boolean>>({});
|
||||
const pendingLiveEntriesRef = useRef<Record<string, AgentLogEntry[]>>({});
|
||||
|
||||
// Create clear function for a specific task
|
||||
const createClearFn = useCallback((taskId: string) => {
|
||||
@@ -40,6 +50,7 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
pendingLiveEntriesRef.current[taskId] = [];
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: [] },
|
||||
@@ -52,12 +63,19 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(taskIds);
|
||||
const sources = sourcesRef.current;
|
||||
const initializing = initializingRef.current;
|
||||
const cancelled = cancelledRef.current;
|
||||
|
||||
// Close connections for tasks no longer in the list
|
||||
for (const [taskId, es] of Object.entries(sources)) {
|
||||
if (!currentIds.has(taskId)) {
|
||||
cancelled[taskId] = true;
|
||||
es.close();
|
||||
delete sources[taskId];
|
||||
initializing.delete(taskId);
|
||||
delete cancelled[taskId];
|
||||
delete pendingLiveEntriesRef.current[taskId];
|
||||
|
||||
// Remove state for disconnected task
|
||||
setStateMap((prev) => {
|
||||
const { [taskId]: _, ...rest } = prev;
|
||||
@@ -66,6 +84,15 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark removed pending initializations as cancelled even if EventSource not created yet
|
||||
for (const taskId of Object.keys(cancelled)) {
|
||||
if (!currentIds.has(taskId)) {
|
||||
cancelled[taskId] = true;
|
||||
initializing.delete(taskId);
|
||||
delete pendingLiveEntriesRef.current[taskId];
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize state and connections for current tasks
|
||||
for (const taskId of taskIds) {
|
||||
// Initialize state if not present
|
||||
@@ -74,68 +101,116 @@ export function useMultiAgentLogs(taskIds: string[]): LogStateMap {
|
||||
return { ...prev, [taskId]: { entries: [], loading: true } };
|
||||
});
|
||||
|
||||
// Skip if already connected
|
||||
if (sources[taskId]) continue;
|
||||
// Skip if already connected or currently initializing
|
||||
if (sources[taskId] || initializing.has(taskId)) continue;
|
||||
|
||||
let cancelled = false;
|
||||
initializing.add(taskId);
|
||||
cancelled[taskId] = false;
|
||||
pendingLiveEntriesRef.current[taskId] = [];
|
||||
|
||||
// Open SSE connection immediately so rerenders cannot race a second setup
|
||||
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
|
||||
sources[taskId] = es;
|
||||
|
||||
const handleAgentLog = (e: MessageEvent) => {
|
||||
if (cancelled[taskId]) return;
|
||||
|
||||
// Fetch historical logs and open SSE
|
||||
const init = async () => {
|
||||
try {
|
||||
const historical = await fetchAgentLogs(taskId);
|
||||
if (cancelled) return;
|
||||
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: historical, loading: false },
|
||||
}));
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
pendingLiveEntriesRef.current[taskId] = capLogEntries([
|
||||
...(pendingLiveEntriesRef.current[taskId] ?? []),
|
||||
entry,
|
||||
]);
|
||||
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: capLogEntries([...current.entries, entry]) },
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: [], loading: false },
|
||||
}));
|
||||
// skip malformed events
|
||||
}
|
||||
|
||||
// Open SSE connection
|
||||
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
|
||||
sources[taskId] = es;
|
||||
|
||||
es.addEventListener("agent:log", (e) => {
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: [...current.entries, entry] },
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
init();
|
||||
const handleError = () => {
|
||||
es.removeEventListener("agent:log", handleAgentLog);
|
||||
es.removeEventListener("error", handleError);
|
||||
|
||||
if (sourcesRef.current[taskId] === es) {
|
||||
es.close();
|
||||
delete sourcesRef.current[taskId];
|
||||
}
|
||||
|
||||
initializingRef.current.delete(taskId);
|
||||
};
|
||||
|
||||
es.addEventListener("agent:log", handleAgentLog);
|
||||
es.addEventListener("error", handleError);
|
||||
|
||||
// Fetch historical logs
|
||||
void fetchAgentLogs(taskId)
|
||||
.then((historical) => {
|
||||
if (cancelled[taskId]) return;
|
||||
|
||||
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: {
|
||||
...prev[taskId],
|
||||
entries: capLogEntries([...historical, ...pendingLive]),
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled[taskId]) return;
|
||||
|
||||
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: capLogEntries(pendingLive), loading: false },
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLiveEntriesRef.current[taskId] = [];
|
||||
initializingRef.current.delete(taskId);
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on effect re-run or unmount
|
||||
return () => {
|
||||
// In Strict Mode, React runs effects twice.
|
||||
// We only want to close connections on actual unmount, not on every cleanup.
|
||||
// The actual closing of connections for removed tasks is handled above.
|
||||
for (const taskId of taskIds) {
|
||||
cancelledRef.current[taskId] = true;
|
||||
|
||||
const es = sourcesRef.current[taskId];
|
||||
if (es) {
|
||||
es.close();
|
||||
delete sourcesRef.current[taskId];
|
||||
}
|
||||
|
||||
initializingRef.current.delete(taskId);
|
||||
}
|
||||
};
|
||||
}, [taskIds]);
|
||||
|
||||
// Close all connections on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const taskId of Object.keys(cancelledRef.current)) {
|
||||
cancelledRef.current[taskId] = true;
|
||||
}
|
||||
|
||||
for (const es of Object.values(sourcesRef.current)) {
|
||||
es.close();
|
||||
}
|
||||
|
||||
sourcesRef.current = {};
|
||||
initializingRef.current.clear();
|
||||
cancelledRef.current = {};
|
||||
pendingLiveEntriesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
|
||||
|
||||
export function useTasks() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
const tasksRef = useRef(tasks);
|
||||
tasksRef.current = tasks;
|
||||
|
||||
@@ -36,14 +37,16 @@ export function useTasks() {
|
||||
|
||||
// SSE live updates
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const es = new EventSource("/api/events");
|
||||
|
||||
es.addEventListener("task:created", (e) => {
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) => [...prev, task]);
|
||||
});
|
||||
};
|
||||
|
||||
es.addEventListener("task:moved", (e) => {
|
||||
const handleMoved = (e: MessageEvent) => {
|
||||
// Payload: { task, from, to } - task object includes server-set columnMovedAt
|
||||
// We use 'to' as the authoritative column and trust the server's columnMovedAt
|
||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||
@@ -53,9 +56,9 @@ export function useTasks() {
|
||||
t.id === normalizedTask.id ? { ...normalizedTask, column: to } : t
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
es.addEventListener("task:updated", (e) => {
|
||||
const handleUpdated = (e: MessageEvent) => {
|
||||
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -80,14 +83,14 @@ export function useTasks() {
|
||||
return incoming;
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
es.addEventListener("task:deleted", (e) => {
|
||||
const handleDeleted = (e: MessageEvent) => {
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||
});
|
||||
};
|
||||
|
||||
es.addEventListener("task:merged", (e) => {
|
||||
const handleMerged = (e: MessageEvent) => {
|
||||
// Payload: { task, branch, merged, worktreeRemoved, branchDeleted, ... }
|
||||
// The task object has already been moved to 'done' by the server
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
@@ -98,18 +101,44 @@ export function useTasks() {
|
||||
t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
setTimeout(() => {
|
||||
if (es.readyState === EventSource.CLOSED) {
|
||||
// Will reconnect via new effect cycle
|
||||
}
|
||||
const cleanup = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
es.removeEventListener("task:created", handleCreated);
|
||||
es.removeEventListener("task:moved", handleMoved);
|
||||
es.removeEventListener("task:updated", handleUpdated);
|
||||
es.removeEventListener("task:deleted", handleDeleted);
|
||||
es.removeEventListener("task:merged", handleMerged);
|
||||
es.removeEventListener("error", handleError);
|
||||
es.close();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
if (closedByCleanup) return;
|
||||
|
||||
cleanup();
|
||||
reconnectTimer = setTimeout(() => {
|
||||
setConnectionNonce((current) => current + 1);
|
||||
}, 3000);
|
||||
});
|
||||
};
|
||||
|
||||
return () => es.close();
|
||||
}, []);
|
||||
es.addEventListener("task:created", handleCreated);
|
||||
es.addEventListener("task:moved", handleMoved);
|
||||
es.addEventListener("task:updated", handleUpdated);
|
||||
es.addEventListener("task:deleted", handleDeleted);
|
||||
es.addEventListener("task:merged", handleMerged);
|
||||
es.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
closedByCleanup = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [connectionNonce]);
|
||||
|
||||
const createTask = useCallback(async (input: TaskCreateInput): Promise<Task> => {
|
||||
return normalizeTask(await api.createTask(input));
|
||||
|
||||
@@ -118,6 +118,38 @@ function buildMultipart(fieldName: string, filename: string, contentType: string
|
||||
return { body, boundary };
|
||||
}
|
||||
|
||||
describe("GET /tasks", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns tasks with optional pagination params", async () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?limit=10&offset=5");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(store.listTasks).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
||||
});
|
||||
|
||||
it("returns 400 for invalid pagination params", async () => {
|
||||
const res = await GET(buildApp(), "/api/tasks?limit=-1");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("limit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /tasks/:id", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -582,9 +582,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
registerModelsRoute(router, options?.modelRegistry);
|
||||
|
||||
// List all tasks
|
||||
router.get("/tasks", async (_req, res) => {
|
||||
router.get("/tasks", async (req, res) => {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined;
|
||||
const offset = typeof req.query.offset === "string" ? Number.parseInt(req.query.offset, 10) : undefined;
|
||||
|
||||
if (limit !== undefined && (!Number.isFinite(limit) || limit < 0)) {
|
||||
res.status(400).json({ error: "limit must be a non-negative integer" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (offset !== undefined && (!Number.isFinite(offset) || offset < 0)) {
|
||||
res.status(400).json({ error: "offset must be a non-negative integer" });
|
||||
return;
|
||||
}
|
||||
|
||||
const tasks = await store.listTasks({ limit, offset });
|
||||
res.json(tasks);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
|
||||
Reference in New Issue
Block a user