perf(dashboard): slim task list + auto-archive stale done tasks

GET /api/tasks was returning ~69 MB of JSON per call (67.9 MB of agent
logs across 1199 tasks), causing the dashboard to hang for 2+ minutes.

- core: extend listTasks() with slim and includeArchived options
- dashboard: GET /api/tasks now uses slim mode and excludes archived
  by default; ?includeArchived=1 opts in
- frontend: lazy-load archived tasks when the archived column is first
  expanded via new useTasks.loadArchivedTasks()
- engine: self-healing maintenance now auto-archives done tasks older
  than 48h (data stays in SQLite, column flips done -> archived)
- tests: slim mode + includeArchived coverage in store.test.ts;
  routes.test.ts assertion updated for new args

Also bundles in-progress test-setup noise filters and pre-existing
QuickEntryBox/routes test work that was already modified locally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-10 20:16:20 -07:00
parent c9a1e94bbe
commit 5984584d22
11 changed files with 333 additions and 82 deletions

View File

@@ -25,6 +25,8 @@ interface BoardProps {
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
/** Lazy-load archived tasks. Called the first time the user expands the archived column. */
onLoadArchivedTasks?: () => Promise<void>;
searchQuery?: string;
availableModels?: ModelInfo[];
/**
@@ -62,8 +64,9 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const { fetchBatch } = useBatchBadgeFetch(projectId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Normalized search-active signal: trimmed and non-empty
@@ -78,8 +81,15 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
});
const handleToggleArchivedCollapse = useCallback(() => {
setArchivedCollapsed((current) => !current);
}, []);
setArchivedCollapsed((current) => {
const next = !current;
if (!next && !archivedLoadedRef.current && onLoadArchivedTasks) {
archivedLoadedRef.current = true;
void onLoadArchivedTasks();
}
return next;
});
}, [onLoadArchivedTasks]);
// Tasks are already server-filtered when searchQuery is active (via useTasks hook).
// Client-side filtering is removed - tasks prop is used directly.

View File

@@ -401,7 +401,8 @@ describe("QuickEntryBox", () => {
});
it("prevents default on Enter key (without Shift)", () => {
renderQuickEntryBox({});
const onCreate = vi.fn(() => new Promise(() => undefined));
renderQuickEntryBox({ onCreate });
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task" } });
@@ -497,7 +498,9 @@ describe("QuickEntryBox", () => {
fireEvent.keyDown(textarea, { key: "Enter" });
// Wait a bit to ensure no async call happens
await new Promise((resolve) => setTimeout(resolve, 50));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(props.onCreate).not.toHaveBeenCalled();
});
@@ -509,7 +512,9 @@ describe("QuickEntryBox", () => {
fireEvent.change(textarea, { target: { value: " " } });
fireEvent.keyDown(textarea, { key: "Enter" });
await new Promise((resolve) => setTimeout(resolve, 50));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(props.onCreate).not.toHaveBeenCalled();
});
@@ -1692,7 +1697,9 @@ describe("QuickEntryBox", () => {
it("loading state disables button during refinement", async () => {
const { refineText } = await import("../../api");
// Slow down the promise to see loading state
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
vi.mocked(refineText).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve("Refined text"), 100)),
);
renderQuickEntryBox({});
expandQuickEntry();