From 03161adfb964e105b65e613b9ee609ee6098a413 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 8 Jul 2026 01:29:56 -0700 Subject: [PATCH] FN-7659: paginate and sort the Archived column newest-first Adds server-side pagination for the Archived task column, sorted by most-recently-archived first, with a Show more control on the dashboard. - Add ArchiveDatabase.listPage and TaskStore.listArchivedTasks for a bounded SQL LIMIT/OFFSET read ordered by archivedAt DESC - Add GET /tasks/archived route for paged archive fetches, leaving the legacy merged listTasks({includeArchived}) path unchanged - Wire useTasks.loadArchivedTasks to fetch page 1 on first Archived-column expand and loadMoreArchivedTasks for subsequent pages - Add a "Show more" affordance in Column.tsx/Board.tsx/MainContent.tsx to trigger loading additional archived pages - Extend taskSorting.ts to keep archived task ordering stable with the new paged data - Add core and dashboard tests covering archive pagination and store/route behavior - Add changeset for @runfusion/fusion (minor) and update docs/storage.md and docs/dashboard-guide.md Files changed: .changeset/FN-7659-archived-pagination.md | 7 + docs/dashboard-guide.md | 4 +- docs/storage.md | 7 + packages/core/src/__tests__/archive-db-pagination.test.ts | 94 ++++++++ packages/core/src/__tests__/store-archive-search.test.ts | 63 ++++++ packages/core/src/archive-db.ts | 18 ++ packages/core/src/store.ts | 32 +++ packages/dashboard/app/App.tsx | 5 +- packages/dashboard/app/api/legacy.ts | 19 ++ packages/dashboard/app/components/Board.tsx | 19 +- packages/dashboard/app/components/Column.tsx | 48 ++++- packages/dashboard/app/components/__tests__/Column.test.tsx | 41 ++++ packages/dashboard/app/components/__tests__/taskSorting.test.ts | 27 +++ packages/dashboard/app/components/dashboard/MainContent.tsx | 9 + packages/dashboard/app/components/dashboard/types.ts | 6 + packages/dashboard/app/components/taskSorting.ts | 16 ++ packages/dashboard/app/hooks/__tests__/useTasks.test.ts | 236 ++++++++++++++++++++- packages/dashboard/app/hooks/useTasks.ts | 173 ++++++++++++++- packages/dashboard/app/test/mockApi.ts | 3 + packages/dashboard/src/routes/__tests__/tasks-archived-pagination.test.ts | 94 ++++++++ packages/dashboard/src/routes/register-task-workflow-routes.ts | 32 +++ 21 files changed, 930 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-7659 Fusion-Task-Lineage: 7a5a1f62-277c-4f29-883c-62e75b269bc5 Co-authored-by: Fusion (runfusion.ai) --- .changeset/FN-7659-archived-pagination.md | 7 + docs/dashboard-guide.md | 4 +- docs/storage.md | 7 + .../__tests__/archive-db-pagination.test.ts | 94 +++++++ .../__tests__/store-archive-search.test.ts | 63 +++++ packages/core/src/archive-db.ts | 18 ++ packages/core/src/store.ts | 32 +++ packages/dashboard/app/App.tsx | 5 +- packages/dashboard/app/api/legacy.ts | 19 ++ packages/dashboard/app/components/Board.tsx | 19 +- packages/dashboard/app/components/Column.tsx | 48 +++- .../app/components/__tests__/Column.test.tsx | 41 +++ .../components/__tests__/taskSorting.test.ts | 27 ++ .../app/components/dashboard/MainContent.tsx | 9 + .../app/components/dashboard/types.ts | 6 + .../dashboard/app/components/taskSorting.ts | 16 ++ .../app/hooks/__tests__/useTasks.test.ts | 236 +++++++++++++++++- packages/dashboard/app/hooks/useTasks.ts | 173 ++++++++++++- packages/dashboard/app/test/mockApi.ts | 3 + .../tasks-archived-pagination.test.ts | 94 +++++++ .../routes/register-task-workflow-routes.ts | 32 +++ 21 files changed, 930 insertions(+), 23 deletions(-) create mode 100644 .changeset/FN-7659-archived-pagination.md create mode 100644 packages/core/src/__tests__/archive-db-pagination.test.ts create mode 100644 packages/dashboard/src/routes/__tests__/tasks-archived-pagination.test.ts diff --git a/.changeset/FN-7659-archived-pagination.md b/.changeset/FN-7659-archived-pagination.md new file mode 100644 index 0000000000..c660dda5e1 --- /dev/null +++ b/.changeset/FN-7659-archived-pagination.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Archived tasks now load newest-first in pages of 100 with a Show more button. +category: feature +dev: Adds ArchiveDatabase.listPage / TaskStore.listArchivedTasks and GET /tasks/archived for a bounded SQL LIMIT/OFFSET read ordered archivedAt DESC. useTasks.loadArchivedTasks fetches page 1 on first Archived-column expand; loadMoreArchivedTasks fetches subsequent pages. No schema change; the legacy merged listTasks({includeArchived}) path is unchanged. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 3d4053757d..0dfee583e0 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -223,7 +223,9 @@ FNXC:TaskCardMobileSelection 2026-07-01-00:00: Mobile Board long-press is a task FNXC:PlanApproval 2026-07-07-00:00 (FN-7653 correction): the switch is intake/planning-column-only — it must not appear on hold (Todo-like) columns, even though hold columns also gate planning-adjacent behavior. The built-in Coding workflow's Todo column (hold trait) was wrongly showing this control; docs now match the corrected intake-only gating. FNXC:TriageRename 2026-07-08-00:00 (FN-7660): the board column formerly labeled "Triage" is canonically "Planning"; docs refer to it as the Planning column throughout. --> - The Planning column actions dropdown includes **Auto-approve plan**. Turning it on sets the project plan approval mode to auto-approve all planned tasks; turning it off returns to the workflow/default plan approval behavior. In workflow-mode Boards, the same switch appears only on the intake/planning column and on the equivalent **All workflows** aggregate intake column — not on hold (Todo-like) or other lifecycle columns. Use Settings → Merge for the full three-state project control, including **Require approval for all tasks**. -- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` defaults to most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) and can be switched from the Done/complete column actions dropdown to descending task ID. In workflow mode, non-archived columns marked with the `complete` flag use the same Done menu items even when their column ID or label is customized. +- Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, and `in-review` remain priority-first with task-ID tie-breaks; `done` defaults to most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) and can be switched from the Done/complete column actions dropdown to descending task ID. In workflow mode, non-archived columns marked with the `complete` flag use the same Done menu items even when their column ID or label is customized. + +- The Archived column loads newest-first (most recently archived first) in server-backed pages of 100 tasks. Expanding the collapsed Archived column fetches the first page; a **Show more** button at the bottom fetches the next page on demand. The full archive is never loaded in one request — large archives page incrementally as the operator scrolls and clicks Show more. - Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The sort actions are only shown in Done/complete column action menus, including custom workflow completion lanes; Archive All Done lives in the same menu when available. - On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. diff --git a/docs/storage.md b/docs/storage.md index bc4fa37442..8e65f1d18c 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -33,6 +33,13 @@ - SQLite operational-log pruning is controlled separately by `settings.operationalLogRetentionDays`. It now prunes `activityLog`, `runAuditEvents`, `agentHeartbeats`, terminal `agentRuns` rows by `endedAt`, and `agentConfigRevisions` by `createdAt`. - Safety invariants for operational pruning: in-flight `agentRuns` (`endedAt IS NULL`) are never deleted, and the most-recent `agentConfigRevisions` row per agent is always preserved even when older than the retention window. +### Archived-column pagination (FN-7659) + +- The Archived board column no longer loads the full archive into memory. `ArchiveDatabase.listPage(limit, offset)` reads a bounded page ordered `archivedAt DESC, rowid DESC` via SQL `LIMIT/OFFSET`, backed by the existing `idxArchivedTasksArchivedAt` index. +- `TaskStore.listArchivedTasks({ limit, offset, slim })` is a dedicated, archive-only read path (default page size 100) that maps paged entries through `archiveEntryToTask` and returns `{ tasks, total, hasMore }` in `archivedAt DESC` order. It intentionally does **not** run the `createdAt ASC` sort used by the merged `listTasks({ includeArchived: true })` path — that merged path (and its non-board consumers: github-tracking reconciler, signal routes, agent-token-usage, self-healing) is unchanged. +- `GET /tasks/archived?limit=&offset=` exposes the paged read with `projectId` scoping and `limit`/`offset` validation, returning the same `{ tasks, total, hasMore }` shape. +- The dashboard's `useTasks` hook loads page 1 on first Archived-column expand and fetches subsequent pages only via an explicit "Show more" click (`loadMoreArchivedTasks`); it never re-fetches the whole archive on SSE reconnect, tab-visibility recovery, or repeated expand calls. Fetched pages merge into the board `tasks` array de-duplicated by id, with active SQLite rows authoritative over archive snapshots. + ### Activity-log no-op `task:moved` cleanup (FN-5940) - `TaskStore` now defends the invariant that `activityLog` never records a `task:moved` row when `metadata.from === metadata.to`. diff --git a/packages/core/src/__tests__/archive-db-pagination.test.ts b/packages/core/src/__tests__/archive-db-pagination.test.ts new file mode 100644 index 0000000000..88eb5991dc --- /dev/null +++ b/packages/core/src/__tests__/archive-db-pagination.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { ArchiveDatabase } from "../archive-db.js"; +import type { ArchivedTaskEntry } from "../types.js"; + +/** + * FNXC:ArchivePagination 2026-07-08-00:00: + * Covers the FN-7659 invariant: the archived read path must return rows + * ordered `archivedAt DESC` and support bounded `LIMIT/OFFSET` windowing + * so the dashboard never loads the whole archive in a single pass. + */ +function makeEntry(id: string, archivedAt: string): ArchivedTaskEntry { + return { + id, + title: `Task ${id}`, + description: "desc", + comments: [], + createdAt: archivedAt, + updatedAt: archivedAt, + archivedAt, + columnMovedAt: archivedAt, + } as unknown as ArchivedTaskEntry; +} + +describe("ArchiveDatabase.listPage", () => { + it("returns [] and total 0 for an empty archive", () => { + const db = new ArchiveDatabase("/tmp/fusion-archive-page-empty", { inMemory: true }); + db.init(); + expect(db.listPage(100, 0)).toEqual([]); + expect(db.getArchivedRowCount()).toBe(0); + }); + + it("orders results by archivedAt DESC (newest first)", () => { + const db = new ArchiveDatabase("/tmp/fusion-archive-page-order", { inMemory: true }); + db.init(); + const base = Date.parse("2026-01-01T00:00:00.000Z"); + for (let i = 0; i < 10; i++) { + db.upsert(makeEntry(`FN-${i}`, new Date(base + i * 60_000).toISOString())); + } + const page = db.listPage(100, 0); + expect(page.map((e) => e.id)).toEqual( + Array.from({ length: 10 }, (_, i) => `FN-${9 - i}`), + ); + }); + + it("windows correctly with LIMIT/OFFSET across page boundaries", () => { + const db = new ArchiveDatabase("/tmp/fusion-archive-page-windows", { inMemory: true }); + db.init(); + const base = Date.parse("2026-01-01T00:00:00.000Z"); + const total = 250; + for (let i = 0; i < total; i++) { + db.upsert(makeEntry(`FN-${i}`, new Date(base + i * 60_000).toISOString())); + } + expect(db.getArchivedRowCount()).toBe(total); + + const page1 = db.listPage(100, 0); + const page2 = db.listPage(100, 100); + const page3 = db.listPage(100, 200); + + expect(page1).toHaveLength(100); + expect(page2).toHaveLength(100); + expect(page3).toHaveLength(50); + + // Newest first: FN-249 is the last-archived (highest archivedAt). + expect(page1[0]!.id).toBe("FN-249"); + expect(page1[99]!.id).toBe("FN-150"); + expect(page2[0]!.id).toBe("FN-149"); + expect(page2[99]!.id).toBe("FN-50"); + expect(page3[0]!.id).toBe("FN-49"); + expect(page3[49]!.id).toBe("FN-0"); + + // No duplicates/gaps across the concatenated pages. + const allIds = [...page1, ...page2, ...page3].map((e) => e.id); + expect(new Set(allIds).size).toBe(total); + }); + + it("handles the exact page-boundary cases (total === 100 and 101)", () => { + const db100 = new ArchiveDatabase("/tmp/fusion-archive-page-100", { inMemory: true }); + db100.init(); + const base = Date.parse("2026-01-01T00:00:00.000Z"); + for (let i = 0; i < 100; i++) { + db100.upsert(makeEntry(`FN-${i}`, new Date(base + i * 60_000).toISOString())); + } + expect(db100.listPage(100, 0)).toHaveLength(100); + expect(db100.listPage(100, 100)).toHaveLength(0); + + const db101 = new ArchiveDatabase("/tmp/fusion-archive-page-101", { inMemory: true }); + db101.init(); + for (let i = 0; i < 101; i++) { + db101.upsert(makeEntry(`FN-${i}`, new Date(base + i * 60_000).toISOString())); + } + expect(db101.listPage(100, 0)).toHaveLength(100); + expect(db101.listPage(100, 100)).toHaveLength(1); + }); +}); diff --git a/packages/core/src/__tests__/store-archive-search.test.ts b/packages/core/src/__tests__/store-archive-search.test.ts index dcc7224870..1fba03ebfa 100644 --- a/packages/core/src/__tests__/store-archive-search.test.ts +++ b/packages/core/src/__tests__/store-archive-search.test.ts @@ -1067,5 +1067,68 @@ describe("searchTasks", () => { }); }); + describe("listArchivedTasks", () => { + it("returns [] / total 0 / hasMore false for an empty archive", async () => { + const result = await store.listArchivedTasks(); + expect(result.tasks).toEqual([]); + expect(result.total).toBe(0); + expect(result.hasMore).toBe(false); + }); + it("returns archived tasks newest-first (archivedAt DESC), not createdAt order", async () => { + const first = await store.createTask({ description: "archived first (oldest createdAt)" }); + const second = await store.createTask({ description: "archived second" }); + const third = await store.createTask({ description: "archived third (newest archivedAt)" }); + + // Archive out of createdAt order so archivedAt DESC and createdAt DESC diverge. + // cleanup:true (the archiveTask default) is required so entries actually + // land in archiveDb; archiveTask(id, false) only flips the tasks-table column. + await store.archiveTask(third.id, true); + await store.archiveTask(first.id, true); + await store.archiveTask(second.id, true); + + const result = await store.listArchivedTasks({ limit: 100, offset: 0 }); + expect(result.total).toBe(3); + expect(result.hasMore).toBe(false); + // Most-recently-archived first: second, first, third. + expect(result.tasks.map((t) => t.id)).toEqual([second.id, first.id, third.id]); + }); + + it("paginates with hasMore true mid-archive and false at the boundary", async () => { + const ids: string[] = []; + for (let i = 0; i < 5; i++) { + const task = await store.createTask({ description: `archive-page-${i}` }); + await store.archiveTask(task.id, true); + ids.push(task.id); + } + + const page1 = await store.listArchivedTasks({ limit: 2, offset: 0 }); + expect(page1.tasks).toHaveLength(2); + expect(page1.total).toBe(5); + expect(page1.hasMore).toBe(true); + + const page2 = await store.listArchivedTasks({ limit: 2, offset: 2 }); + expect(page2.tasks).toHaveLength(2); + expect(page2.hasMore).toBe(true); + + const page3 = await store.listArchivedTasks({ limit: 2, offset: 4 }); + expect(page3.tasks).toHaveLength(1); + expect(page3.hasMore).toBe(false); + + // No dup/gap across pages. + const allIds = [...page1.tasks, ...page2.tasks, ...page3.tasks].map((t) => t.id); + expect(new Set(allIds).size).toBe(5); + }); + + it("slim defaults to true (drops log) while slim:false preserves full payload shape", async () => { + const task = await store.createTask({ description: "slim-shape-check" }); + await store.archiveTask(task.id, true); + + const slimResult = await store.listArchivedTasks(); + expect(slimResult.tasks[0]!.log).toEqual([]); + + const fullResult = await store.listArchivedTasks({ slim: false }); + expect(fullResult.tasks[0]!.id).toBe(task.id); + }); + }); }); diff --git a/packages/core/src/archive-db.ts b/packages/core/src/archive-db.ts index cb0069fb87..bfd3620563 100644 --- a/packages/core/src/archive-db.ts +++ b/packages/core/src/archive-db.ts @@ -135,6 +135,24 @@ export class ArchiveDatabase { return rows.map((row) => JSON.parse(row.taskJson) as ArchivedTaskEntry); } + /** + * FNXC:ArchivePagination 2026-07-08-00:00: + * The Archived board column must render newest-first (`archivedAt DESC`) and + * must never load the whole archive into memory in one pass — large archives + * (thousands of rows) made `list()` a heavy one-shot payload + render. This + * bounded SQL `LIMIT/OFFSET` read backs a chunk-of-100 "Show more" UI: each + * call fetches one page, ordered `archivedAt DESC` with a deterministic + * `rowid DESC` tie-break for rows sharing the same archivedAt timestamp. + */ + listPage(limit: number, offset: number): ArchivedTaskEntry[] { + const rows = this.db.prepare(` + SELECT taskJson FROM archived_tasks + ORDER BY archivedAt DESC, rowid DESC + LIMIT ? OFFSET ? + `).all(limit, offset) as Array<{ taskJson: string }>; + return rows.map((row) => JSON.parse(row.taskJson) as ArchivedTaskEntry); + } + get(id: string): ArchivedTaskEntry | undefined { const row = this.db.prepare("SELECT taskJson FROM archived_tasks WHERE id = ?").get(id) as | { taskJson: string } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index e8745ebaed..efb933fc46 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6162,6 +6162,38 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return sorted.slice(offset, offset + Math.max(0, limit)); } + /** + * FNXC:ArchivePagination 2026-07-08-00:00: + * Dedicated archived-only read path for the Archived board column. The + * merged `listTasks({includeArchived:true})` path re-sorts everything + * (active + archived) by `createdAt ASC`, which is correct for the merged + * consumers (github-tracking reconciler, signal routes, agent-token-usage, + * self-healing) but wrong for the Archived column (must be newest-first) + * and unbounded (loads the whole archive). This method reads ONLY from + * `archiveDb` via the bounded `listPage()` SQL LIMIT/OFFSET query and + * preserves `archivedAt DESC` order — it must NOT be re-sorted by + * createdAt. Default page size is 100 to back a chunk-of-100 "Show more" + * UI; do not use this method as a substitute for the merged path. + */ + async listArchivedTasks(options?: { + limit?: number; + offset?: number; + slim?: boolean; + }): Promise<{ tasks: Task[]; total: number; hasMore: boolean }> { + const rawLimit = options?.limit ?? 100; + const limit = Math.min(500, Math.max(1, Math.trunc(rawLimit) || 100)); + const rawOffset = options?.offset ?? 0; + const offset = Math.max(0, Math.trunc(rawOffset) || 0); + const slim = options?.slim ?? true; + + const total = this.archiveDb.getArchivedRowCount(); + const entries = this.archiveDb.listPage(limit, offset); + const tasks = entries.map((entry) => this.archiveEntryToTask(entry, slim)); + const hasMore = offset + tasks.length < total; + + return { tasks, total, hasMore }; + } + /** * Residual B (U13/U9): per-branch progress snapshots for the given tasks, * read from the `workflow_run_branches` table. Used to populate the optional diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 14c135e307..1e787981fe 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -441,7 +441,7 @@ function AppInner() { // FNXC:DashboardLiveUpdates 2026-06-26-01:08: // SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry. const taskSseEnabled = taskView === "board" || taskView === "list"; - const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks( + const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, loadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, ingestCreatedTasks, lastFetchTimeMs } = useTasks( { ...(currentProject ? { projectId: currentProject.id } : {}), searchQuery: searchQuery || undefined, @@ -1398,6 +1398,9 @@ function AppInner() { deleteTask, archiveAllDone, loadArchivedTasks, + loadMoreArchivedTasks, + archivedHasMore, + archivedLoadingMore, searchQuery, availableModels, favoriteProviders, diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index c8b79a877b..a3a9a2dd42 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -299,6 +299,25 @@ export function fetchTasks( return api(`/tasks${suffix}`); } +/** + * FNXC:ArchivePagination 2026-07-08-00:00: + * Dedicated paged read for the Archived board column (FN-7659). Returns + * one bounded page (default 100) ordered `archivedAt DESC` plus `total`/ + * `hasMore` so the caller can drive a "Show more" affordance without ever + * fetching the whole archive in one request. + */ +export function fetchArchivedTasks( + projectId?: string, + limit?: number, + offset?: number, +): Promise<{ tasks: Task[]; total: number; hasMore: boolean }> { + 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<{ tasks: Task[]; total: number; hasMore: boolean }>(withProjectId(`/tasks/archived${suffix}`, projectId)); +} + export async function fetchTaskDetail(id: string, projectId?: string): Promise { const maxAttempts = 2; // 1 initial + 1 retry const url = buildApiUrl(withProjectId(`/tasks/${id}`, projectId)); diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 854ac072bc..96eda900c1 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -65,6 +65,12 @@ interface BoardProps { onArchiveAllDone?: () => Promise; /** Lazy-load archived tasks. Called the first time the user expands the archived column. */ onLoadArchivedTasks?: () => Promise; + /** FNXC:ArchivePagination 2026-07-08-00:00: FN-7659 — fetch the next 100-item page of archived tasks (newest-first). Threaded to the Archived column's server-backed "Show more" button. */ + onLoadMoreArchivedTasks?: () => Promise; + /** Whether another archived page is available beyond what is currently loaded. */ + archivedHasMore?: boolean; + /** True while a "Show more" archived page fetch is in flight. */ + archivedLoadingMore?: boolean; searchQuery?: string; availableModels?: ModelInfo[]; /** @@ -160,7 +166,7 @@ function BoardWorkflowSkeleton({ empty = false }: { empty?: boolean }) { ); } -export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { +export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowColumnsEnabled, settingsLoaded, workflowControlsInHeader = false }: BoardProps) { const [archivedCollapsed, setArchivedCollapsed] = useState(true); /* FNXC:DoneColumnSorting 2026-06-29-16:57: @@ -636,7 +642,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o const isWorkflowDoneLikeColumn = column.flags.complete === true && column.flags.archived !== true; grouped[column.id] = isWorkflowDoneLikeColumn ? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode) - : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType); + : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, doneSortMode, column.flags.archived === true); } return grouped; }, [doneSortMode, selectedWorkflow, selectedWorkflowCreateColumnId, selectedWorkflowTasks]); @@ -790,7 +796,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o const isDoneLikeColumn = column.flags.complete === true && column.flags.archived !== true; grouped[column.id] = isDoneLikeColumn ? sortTasksForDisplayColumn(grouped[column.id] ?? [], "done", doneSortMode) - : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType); + : sortTasksForDisplayColumn(grouped[column.id] ?? [], column.id as ColumnType, doneSortMode, column.flags.archived === true); } return grouped; }, [aggregateBoardColumns, doneSortMode, getEffectiveTaskWorkflowId, tasks, workflowColumnsByWorkflowId]); @@ -912,7 +918,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o {...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} - {...(columnDef.flags.archived ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})} + {...(columnDef.flags.archived ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})} /> ); })} @@ -1048,6 +1054,9 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o mergeStrategy={mergeStrategy} collapsed={archivedCollapsed} onToggleCollapse={handleToggleArchivedCollapse} + archivedHasMore={archivedHasMore} + archivedLoadingMore={archivedLoadingMore} + onLoadMoreArchived={onLoadMoreArchivedTasks} /> )} @@ -1103,7 +1112,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o {...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})} {...(col === "in-review" ? { onToggleAutoMerge: handleToggleAutoMerge } : {})} {...(col === "done" ? { onArchiveAllDone, doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} - {...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})} + {...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived: onLoadMoreArchivedTasks } : {})} /> ))} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index d7cf63a512..4a4a304e97 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -134,6 +134,12 @@ interface ColumnProps { onDoneSortModeChange?: (mode: DoneColumnSortMode) => void; collapsed?: boolean; onToggleCollapse?: () => void; + /** FNXC:ArchivePagination 2026-07-08-00:00: FN-7659 — whether another archived page (beyond what's currently loaded) is available. Drives the archived column's server-backed "Show more" button. */ + archivedHasMore?: boolean; + /** True while a "Show more" archived page fetch is in flight. */ + archivedLoadingMore?: boolean; + /** Fetches the next 100-item page of archived tasks (newest-first). */ + onLoadMoreArchived?: () => Promise; allTasks?: Task[]; availableModels?: ModelInfo[]; /** @@ -199,7 +205,7 @@ interface ColumnProps { getDraggingTaskId?: () => string | null; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { const { t } = useTranslation("app"); // Anchor the board.rejection.* catalog keys for the i18next extractor (it // scopes `t` to the useTranslation binding, so the shared translateRejection @@ -442,6 +448,24 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree setVisibleTaskCount((current) => Math.min(current + VISIBLE_TASKS_INCREMENT, tasks.length)); }, [tasks.length]); + /* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 — the Archived column's "Show more" is server-backed (fetches the + next 100-item page ordered `archivedAt DESC` from the API), distinct from + `handleLoadMore` above which only reveals more of an already-fetched + client-side array for non-archived columns. + */ + const [isLoadingMoreArchived, setIsLoadingMoreArchived] = useState(false); + const handleLoadMoreArchived = useCallback(async () => { + if (!onLoadMoreArchived || isLoadingMoreArchived) return; + setIsLoadingMoreArchived(true); + try { + await onLoadMoreArchived(); + } finally { + setIsLoadingMoreArchived(false); + } + }, [onLoadMoreArchived, isLoadingMoreArchived]); + const handleReplanAll = useCallback(async () => { setIsMenuOpen(false); if (tasks.length === 0) return; @@ -925,6 +949,28 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree {t("column.loadMore", "Load {{count}} more ({{remaining}} remaining)", { count: Math.min(VISIBLE_TASKS_INCREMENT, hiddenTaskCount), remaining: hiddenTaskCount })} )} + {/* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 — the Archived column's "Show more" only renders when the + server reports another page beyond what's currently loaded + (archivedHasMore), so an empty archive or an archive smaller than + one page never shows the button (no empty shell on desktop or + mobile). Distinct from the shouldPaginate/handleLoadMore button + above, which reveals more of an already-fetched client array for + non-archived columns. + */} + {isArchived && archivedHasMore && ( + + )} )} diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 8450287c30..4b29da29e8 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -440,6 +440,47 @@ describe("Column pagination", () => { expect(screen.queryByRole("button", { name: /Load 25 more/i })).toBeNull(); }); + /* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 — the Archived column's server-backed "Show more" is a distinct + affordance from the client-side Load-more button covered above: it renders + only when `archivedHasMore` is true, is absent for an empty/under-one-page + archive, and invokes `onLoadMoreArchived` (not the client-side visible-count + bump) when clicked. + */ + describe("archived pagination (FN-7659)", () => { + const archivedTasks = Array.from({ length: 3 }, (_, index) => ({ + ...makeTask(`KB-ARCH-${index + 1}`), + column: "archived" as ColumnType, + })); + + it("shows the server-backed Show more button only when archivedHasMore is true", () => { + render(); + expect(screen.queryByRole("button", { name: /Show more/i })).toBeNull(); + }); + + it("renders no Show more button for an empty archive", () => { + render(); + expect(screen.queryByRole("button", { name: /Show more/i })).toBeNull(); + }); + + it("renders the Show more button when archivedHasMore is true and invokes onLoadMoreArchived on click", async () => { + const onLoadMoreArchived = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + render(); + + const button = screen.getByRole("button", { name: /Show more/i }); + await user.click(button); + + expect(onLoadMoreArchived).toHaveBeenCalledTimes(1); + }); + + it("does not render the Show more button when the archived column is collapsed", () => { + render(); + expect(screen.queryByRole("button", { name: /Show more/i })).toBeNull(); + }); + }); + it("disables pagination when isSearchActive is true, showing all tasks", () => { const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`)); render(); diff --git a/packages/dashboard/app/components/__tests__/taskSorting.test.ts b/packages/dashboard/app/components/__tests__/taskSorting.test.ts index 12976b55fe..fff8221141 100644 --- a/packages/dashboard/app/components/__tests__/taskSorting.test.ts +++ b/packages/dashboard/app/components/__tests__/taskSorting.test.ts @@ -27,6 +27,33 @@ describe("sortTasksForDisplayColumn", () => { expect(sortTasksForDisplayColumn([], "done")).toEqual([]); }); + /* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 — the Archived column's server-fetched order (`archivedAt DESC`, + newest-first) must not be re-sorted by priority/task-id like every other + non-todo/non-done column. Both the legacy literal "archived" column id and + the explicit isArchivedColumn flag (for workflow-mode custom archived + columns) must pass the incoming order through unchanged. + */ + it("passes through the incoming order unchanged for the legacy 'archived' column id", () => { + const tasks = [ + task({ id: "FN-3", priority: "low" }), + task({ id: "FN-1", priority: "urgent" }), + task({ id: "FN-2", priority: "normal" }), + ]; + + expect(ids(sortTasksForDisplayColumn(tasks, "archived"))).toEqual(["FN-3", "FN-1", "FN-2"]); + }); + + it("passes through the incoming order unchanged when isArchivedColumn is explicitly true", () => { + const tasks = [ + task({ id: "FN-9", priority: "low" }), + task({ id: "FN-5", priority: "urgent" }), + ]; + + expect(ids(sortTasksForDisplayColumn(tasks, "todo", "completion-date-desc", true))).toEqual(["FN-9", "FN-5"]); + }); + it("defaults Done to completion-date descending with numeric task-id ascending ties", () => { const tasks = [ task({ id: "FN-7240", columnMovedAt: "2026-06-01T00:00:00.000Z" }), diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 428ba00ac4..df543c4d7a 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -134,6 +134,9 @@ export function MainContent({ deleteTask, archiveAllDone, loadArchivedTasks, + loadMoreArchivedTasks, + archivedHasMore, + archivedLoadingMore, searchQuery, availableModels, favoriteProviders, @@ -728,6 +731,9 @@ export function MainContent({ onDeleteTask={deleteTask} onArchiveAllDone={archiveAllDone} onLoadArchivedTasks={loadArchivedTasks} + onLoadMoreArchivedTasks={loadMoreArchivedTasks} + archivedHasMore={archivedHasMore} + archivedLoadingMore={archivedLoadingMore} searchQuery={searchQuery} availableModels={availableModels} onOpenDetailWithTab={handleOpenDetailWithTab} @@ -842,6 +848,9 @@ export function MainContent({ onDeleteTask={deleteTask} onArchiveAllDone={archiveAllDone} onLoadArchivedTasks={loadArchivedTasks} + onLoadMoreArchivedTasks={loadMoreArchivedTasks} + archivedHasMore={archivedHasMore} + archivedLoadingMore={archivedLoadingMore} searchQuery={searchQuery} availableModels={availableModels} onOpenDetailWithTab={handleOpenDetailWithTab} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index 9ff8e438a7..c661353676 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -196,6 +196,12 @@ export interface MainContentProps { ) => Promise; archiveAllDone: () => Promise; loadArchivedTasks: () => Promise; + /** FNXC:ArchivePagination 2026-07-08-00:00: FN-7659 — fetch the next 100-item page of archived tasks (newest-first). */ + loadMoreArchivedTasks: () => Promise; + /** Whether another page of archived tasks is available beyond what is currently loaded. */ + archivedHasMore: boolean; + /** True while a "Show more" archived page fetch is in flight. */ + archivedLoadingMore: boolean; searchQuery: string; availableModels: ModelInfo[]; favoriteProviders: string[]; diff --git a/packages/dashboard/app/components/taskSorting.ts b/packages/dashboard/app/components/taskSorting.ts index b8ce1d804a..eecea72dd4 100644 --- a/packages/dashboard/app/components/taskSorting.ts +++ b/packages/dashboard/app/components/taskSorting.ts @@ -56,7 +56,23 @@ export function sortTasksForDisplayColumn( tasks: readonly Task[], column: Column, doneSortMode: DoneColumnSortMode = "completion-date-desc", + isArchivedColumn: boolean = column === "archived", ): Task[] { + /* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 — the Archived column must render newest-first (`archivedAt DESC`), + the exact order the paginated `GET /tasks/archived` read and useTasks' + page-merge already produce. The generic priority+task-id sort below would + silently undo that ordering (it ran for every non-todo/non-done column, + including archived, before this fix), so archived columns pass through + unsorted — both the legacy literal `"archived"` column id and, for + workflow-mode custom archived columns, the caller-supplied + `isArchivedColumn` flag (derived from the column's `archived` trait flag). + */ + if (isArchivedColumn) { + return [...tasks]; + } + if (column === "todo") { return [...tasks].sort((a, b) => { const priorityCmp = compareTaskPriority(a.priority, b.priority); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 7cf372f803..a52ccd40b5 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -47,6 +47,7 @@ async function flushPromises(): Promise { } const mockFetchTasks = vi.mocked(api.fetchTasks); +const mockFetchArchivedTasks = vi.mocked(api.fetchArchivedTasks); const mockCreateTask = vi.mocked(api.createTask); const mockDeleteTask = vi.mocked(api.deleteTask); const mockRetryTask = vi.mocked(api.retryTask); @@ -98,6 +99,7 @@ beforeEach(() => { MockEventSource.instances = []; (globalThis as any).EventSource = MockEventSource; mockFetchTasks.mockReset().mockResolvedValue([]); + mockFetchArchivedTasks.mockReset().mockResolvedValue({ tasks: [], total: 0, hasMore: false }); mockDeleteTask.mockReset(); mockRetryTask.mockReset(); mockReadCache.mockReset(); @@ -1576,9 +1578,8 @@ describe("useTasks", () => { it("removes archived-loaded tasks without disturbing active rows", async () => { const active = createMockTask({ id: "FN-ACTIVE", column: "todo" as Column }); const archived = createMockTask({ id: "FN-ARCHIVED", column: "archived" as Column }); - mockFetchTasks - .mockResolvedValueOnce([active]) - .mockResolvedValueOnce([active, archived]); + mockFetchTasks.mockResolvedValueOnce([active]); + mockFetchArchivedTasks.mockResolvedValueOnce({ tasks: [archived], total: 1, hasMore: false }); mockDeleteTask.mockResolvedValueOnce(archived); const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); @@ -1589,7 +1590,6 @@ describe("useTasks", () => { await result.current.loadArchivedTasks(); }); - expect(result.current.includeArchived).toBe(true); expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-ACTIVE", "FN-ARCHIVED"]); await act(async () => { @@ -1600,6 +1600,234 @@ describe("useTasks", () => { }); }); + /* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 — the Archived column must load newest-first in server-backed pages + of 100, never the whole archive in one pass. These tests assert the + dedicated GET /tasks/archived-backed page-1/"Show more" contract: exactly + one page-1 request on first expand, exactly one next-page request per + loadMoreArchivedTasks() call, correct archivedHasMore transitions, and that + the legacy merged fetchTasks(...,includeArchived) path is never invoked by + this flow. + */ + describe("archived pagination (FN-7659)", () => { + it("loadArchivedTasks fetches exactly one page-1 request and never the whole archive via fetchTasks", async () => { + const active = createMockTask({ id: "FN-ACTIVE", column: "todo" as Column }); + const archivedPage = [ + createMockTask({ id: "FN-NEW", column: "archived" as Column }), + createMockTask({ id: "FN-OLD", column: "archived" as Column }), + ]; + mockFetchTasks.mockResolvedValueOnce([active]); + mockFetchArchivedTasks.mockResolvedValueOnce({ tasks: archivedPage, total: 2, hasMore: false }); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + await waitFor(() => expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-ACTIVE"])); + + await act(async () => { + await result.current.loadArchivedTasks(); + }); + + expect(mockFetchArchivedTasks).toHaveBeenCalledTimes(1); + expect(mockFetchArchivedTasks).toHaveBeenCalledWith("proj-1", 100, 0); + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-ACTIVE", "FN-NEW", "FN-OLD"]); + expect(result.current.archivedHasMore).toBe(false); + // fetchTasks must never be called with includeArchived=true by this flow. + for (const call of mockFetchTasks.mock.calls) { + expect(call[4]).not.toBe(true); + } + }); + + it("loadArchivedTasks is a no-op on repeated calls (single page-1 fetch across re-expands)", async () => { + const archivedPage = [createMockTask({ id: "FN-ARCHIVED-1", column: "archived" as Column })]; + mockFetchTasks.mockResolvedValueOnce([]); + mockFetchArchivedTasks.mockResolvedValueOnce({ tasks: archivedPage, total: 1, hasMore: false }); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalled()); + + await act(async () => { + await result.current.loadArchivedTasks(); + }); + await act(async () => { + await result.current.loadArchivedTasks(); + }); + + expect(mockFetchArchivedTasks).toHaveBeenCalledTimes(1); + }); + + it("loadMoreArchivedTasks fetches only the next page and flips archivedHasMore at the boundary", async () => { + mockFetchTasks.mockResolvedValueOnce([]); + mockFetchArchivedTasks + .mockResolvedValueOnce({ + tasks: [createMockTask({ id: "FN-P1", column: "archived" as Column })], + total: 2, + hasMore: true, + }) + .mockResolvedValueOnce({ + tasks: [createMockTask({ id: "FN-P2", column: "archived" as Column })], + total: 2, + hasMore: false, + }); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + await waitFor(() => expect(mockFetchTasks).toHaveBeenCalled()); + + await act(async () => { + await result.current.loadArchivedTasks(); + }); + expect(result.current.archivedHasMore).toBe(true); + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-P1"]); + + await act(async () => { + await result.current.loadMoreArchivedTasks(); + }); + + expect(mockFetchArchivedTasks).toHaveBeenCalledTimes(2); + expect(mockFetchArchivedTasks).toHaveBeenLastCalledWith("proj-1", 100, 1); + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-P1", "FN-P2"]); + expect(result.current.archivedHasMore).toBe(false); + + // Calling again once exhausted must not issue another request. + await act(async () => { + await result.current.loadMoreArchivedTasks(); + }); + expect(mockFetchArchivedTasks).toHaveBeenCalledTimes(2); + }); + + /* + FNXC:ArchivePagination 2026-07-08-01:30: + Code review (FN-7659) found a generic refresh after expanding the + Archived column (SSE reconnect resync, tab-visibility regain, or a + search that gets cleared back to "") silently wiped the merged archived + rows from `tasks` because `refreshTasks` always fetches with + `includeArchived=false` and replaced `tasks` wholesale. These tests + assert the fix: archived rows merged in by `loadArchivedTasks` survive + each of those refresh paths, and `fetchTasks` is never called with + `includeArchived=true` by them (no full-archive fetch reintroduced). + */ + it("keeps merged archived rows after an SSE reconnect resync refresh", async () => { + vi.useFakeTimers(); + const active = createMockTask({ id: "FN-ACTIVE", column: "todo" as Column }); + const archivedPage = [createMockTask({ id: "FN-ARCHIVED-1", column: "archived" as Column })]; + mockFetchTasks.mockResolvedValueOnce([active]).mockResolvedValueOnce([active]); + mockFetchArchivedTasks.mockResolvedValueOnce({ tasks: archivedPage, total: 1, hasMore: false }); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + await act(async () => { + await flushPromises(); + }); + + await act(async () => { + await result.current.loadArchivedTasks(); + }); + expect(result.current.tasks.map((task) => task.id).sort()).toEqual(["FN-ACTIVE", "FN-ARCHIVED-1"]); + + const first = MockEventSource.instances[0]; + act(() => { + first._emit("error"); + }); + await act(async () => { + vi.advanceTimersByTime(3000); + await flushPromises(); + }); + + expect(result.current.tasks.map((task) => task.id).sort()).toEqual(["FN-ACTIVE", "FN-ARCHIVED-1"]); + for (const call of mockFetchTasks.mock.calls) { + expect(call[4]).not.toBe(true); + } + expect(mockFetchArchivedTasks).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("keeps merged archived rows after a tab-visibility-regain refresh", async () => { + const visibilityState = { value: "visible" as VisibilityState }; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState.value, + }); + const active = createMockTask({ id: "FN-ACTIVE", column: "todo" as Column }); + const archivedPage = [createMockTask({ id: "FN-ARCHIVED-1", column: "archived" as Column })]; + mockFetchTasks.mockResolvedValue([active]); + mockFetchArchivedTasks.mockResolvedValueOnce({ tasks: archivedPage, total: 1, hasMore: false }); + + const { result } = renderHook(() => useTasks({ projectId: "proj-1" })); + await act(async () => { + await flushPromises(); + }); + + await act(async () => { + await result.current.loadArchivedTasks(); + }); + expect(result.current.tasks.map((task) => task.id).sort()).toEqual(["FN-ACTIVE", "FN-ARCHIVED-1"]); + + visibilityState.value = "hidden"; + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + visibilityState.value = "visible"; + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await flushPromises(); + }); + + expect(result.current.tasks.map((task) => task.id).sort()).toEqual(["FN-ACTIVE", "FN-ARCHIVED-1"]); + for (const call of mockFetchTasks.mock.calls) { + expect(call[4]).not.toBe(true); + } + }); + + it("restores archived matches via bounded search and keeps them after clearing the query", async () => { + vi.useFakeTimers(); + const active = createMockTask({ id: "FN-ACTIVE", column: "todo" as Column }); + const archivedPage = [createMockTask({ id: "FN-ARCHIVED-1", column: "archived" as Column, title: "widget" })]; + const archivedSearchMatch = createMockTask({ id: "FN-ARCHIVED-2", column: "archived" as Column, title: "widget" }); + mockFetchTasks + .mockResolvedValueOnce([active]) // initial mount fetch + .mockResolvedValueOnce([active, archivedSearchMatch]) // search fetch (includeArchived=true) + .mockResolvedValueOnce([active]); // cleared-query fetch (includeArchived=false) + mockFetchArchivedTasks.mockResolvedValueOnce({ tasks: archivedPage, total: 1, hasMore: false }); + + const { result, rerender } = renderHook( + ({ searchQuery }: { searchQuery: string }) => useTasks({ projectId: "proj-1", searchQuery }), + { initialProps: { searchQuery: "" } }, + ); + await act(async () => { + await flushPromises(); + }); + + await act(async () => { + await result.current.loadArchivedTasks(); + }); + expect(result.current.tasks.map((task) => task.id).sort()).toEqual(["FN-ACTIVE", "FN-ARCHIVED-1"]); + + rerender({ searchQuery: "widget" }); + await act(async () => { + vi.advanceTimersByTime(300); + await flushPromises(); + }); + + // The search-triggered fetch must have requested archived matches directly + // (bounded via the server's archiveDb.search), once the column had been expanded. + const searchCall = mockFetchTasks.mock.calls[1]; + expect(searchCall?.[3]).toBe("widget"); + expect(searchCall?.[4]).toBe(true); + expect(result.current.tasks.map((task) => task.id).sort()).toEqual(["FN-ACTIVE", "FN-ARCHIVED-2"]); + + rerender({ searchQuery: "" }); + await act(async () => { + vi.advanceTimersByTime(300); + await flushPromises(); + }); + + // Clearing the query falls back to a non-archived fetch, but the previously + // merged archived row must be carried forward rather than dropped. + const clearedCall = mockFetchTasks.mock.calls[2]; + expect(clearedCall?.[4]).not.toBe(true); + expect(result.current.tasks.map((task) => task.id).sort()).toEqual(["FN-ACTIVE", "FN-ARCHIVED-1"]); + vi.useRealTimers(); + }); + }); + describe("retryTask", () => { it("FN-7295 immediately replaces every matching local retry task without SSE or refresh", async () => { const failedOne = createMockTask({ diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 1944a8f272..c0c5d45a58 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -148,9 +148,15 @@ export function useTasks(options?: UseTasksOptions) { const [lastRefreshErrorAt, setLastRefreshErrorAt] = useState(null); // Once the user expands the archived column, we keep including archived tasks // in subsequent refreshes for the lifetime of this hook instance. - const [includeArchived, setIncludeArchived] = useState(false); + /* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 retired the merged-refresh path this flag used to drive (see the + loadArchivedTasks note below): nothing sets it true anymore, so it is kept + as a stable `false` constant purely for return-type/back-compat rather + than reactive state. + */ + const includeArchived = false; const includeArchivedRef = useRef(includeArchived); - includeArchivedRef.current = includeArchived; const tasksRef = useRef(tasks); const fetchVersionRef = useRef(0); // Tracks the project context version to detect stale SSE events after project switches. @@ -170,6 +176,20 @@ export function useTasks(options?: UseTasksOptions) { const lastConfirmedIncludeArchivedRef = useRef(false); // Track previous projectId to detect changes const previousProjectIdRef = useRef(projectId); + /* + FNXC:ArchivePagination 2026-07-08-01:30: + Declared ahead of `refreshTasks` (rather than alongside the rest of the + archived-pagination state below) because `refreshTasks` reads it on every + generic refresh to decide whether to carry merged archived rows forward. + Code review (FN-7659) found `refreshTasks`'s unconditional + `setTasks(normalizedFetchedTasks)` silently discarded the archived page(s) + merged in by `loadArchivedTasks`/`loadMoreArchivedTasks` on the very next + SSE reconnect, tab-visibility regain, delete-invalidation refresh, or + search-then-clear — making `loadArchivedTasks` a permanent no-op + (`archivedLoadedRef.current` stays true) and silently emptying the + Archived column for the rest of the session. + */ + const archivedLoadedRef = useRef(false); tasksRef.current = tasks; searchQueryRef.current = searchQuery; @@ -190,7 +210,20 @@ export function useTasks(options?: UseTasksOptions) { const requestVersion = ++fetchVersionRef.current; const requestProjectId = projectId; // Capture the projectId for this request const query = options?.searchQueryOverride ?? searchQueryRef.current; - const wantArchived = options?.includeArchivedOverride ?? includeArchivedRef.current; + /* + FNXC:ArchivePagination 2026-07-08-01:30: + When a search query is active and the user has expanded the Archived + column at least once this session, include archived rows in the + search-scoped fetch by default (unless the caller explicitly overrides). + This is bounded — the merged `listTasks`/`searchTasks` archived branch + already runs through `archiveDb.search()`'s own limit, not a full-table + load — and restores the pre-FN-7659 behavior where, once expanded, + search results included archived matches. A cleared/empty query falls + back to the narrow legacy `includeArchivedRef` (always false) so an + ordinary refresh never re-triggers a merged archived fetch; the Archived + column's own rows are instead carried forward below. + */ + const wantArchived = options?.includeArchivedOverride ?? (query ? archivedLoadedRef.current : includeArchivedRef.current); try { const fetchedTasks = await api.fetchTasks(undefined, undefined, requestProjectId, query, wantArchived); @@ -199,7 +232,38 @@ export function useTasks(options?: UseTasksOptions) { return; } const normalizedFetchedTasks = filterActiveTasks(fetchedTasks.map(normalizeTask)); - setTasks(normalizedFetchedTasks); + /* + FNXC:ArchivePagination 2026-07-08-01:30: + A generic refresh (SSE reconnect resync, tab-visibility regain, delete- + fetch invalidation, project switch, or a search that has been cleared + back to "") always fetches with `includeArchived=false` and would + otherwise blow away any archived page(s) already merged in by + `loadArchivedTasks`/`loadMoreArchivedTasks`, making the Archived column + go silently empty and `loadArchivedTasks` a permanent no-op for the + rest of the session (code review finding, FN-7659). When this fetch + did not itself request archived rows and there is no active search + filter, carry the previously merged archived rows (`column === + "archived"`) forward from the latest task state instead of discarding + them; active/non-archived rows from the fresh fetch stay authoritative + by id. A non-empty search query intentionally skips carry-over: `wantArchived` + is already derived above from `archivedLoadedRef` for query-bearing + fetches, so search results include fresh, query-matched archived rows + directly and boundedly (via `archiveDb.search()`'s own limit) rather + than re-showing stale, query-unfiltered archived cards from this branch. + Carry-over reads from `archivedTasksRef` (the canonical accumulator + maintained by `mergeArchivedPage`), not from the previous `tasks` + state, so a search that temporarily narrowed `tasks` to only its + matches cannot cause previously loaded archived rows to be lost once + the query is cleared. + */ + const shouldCarryOverArchived = !wantArchived && !query && archivedLoadedRef.current; + if (shouldCarryOverArchived) { + const freshIds = new Set(normalizedFetchedTasks.map((task) => task.id)); + const archivedCarryOver = archivedTasksRef.current.filter((task) => !freshIds.has(task.id)); + setTasks(archivedCarryOver.length > 0 ? [...normalizedFetchedTasks, ...archivedCarryOver] : normalizedFetchedTasks); + } else { + setTasks(normalizedFetchedTasks); + } if (requestProjectId) { const cachedPayload = fetchedTasks.length > 500 ? fetchedTasks.slice(0, 500) : fetchedTasks; writeCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`, cachedPayload, { maxBytes: 500_000 }); @@ -258,14 +322,91 @@ export function useTasks(options?: UseTasksOptions) { } }, [shouldRefreshOnTaskViewReentry, sseEnabled]); - /** Lazy-load archived tasks. Called by the Board when the archived column is first expanded. */ - const loadArchivedTasks = useCallback(async () => { - if (includeArchivedRef.current) return; - setIncludeArchived(true); - includeArchivedRef.current = true; - await refreshTasksRef.current({ includeArchivedOverride: true }); + /* + FNXC:ArchivePagination 2026-07-08-00:00: + FN-7659 — the Archived column must load newest-first (`archivedAt DESC`) in + server-backed pages of 100 with an explicit "Show more" affordance, and the + full archive must never load into memory in one pass. The prior + implementation flipped `includeArchived` and re-ran the merged `refreshTasks` + (backed by `listTasks({includeArchived:true})`), which (a) sorted archived + rows oldest-first alongside active rows and (b) fetched the ENTIRE archive + on every subsequent refresh (SSE reconnect, tab-visibility recovery, + search) once the column had ever been expanded. `loadArchivedTasks`/ + `loadMoreArchivedTasks` now call the dedicated `GET /tasks/archived` page + read and merge only the fetched page into `tasks` (de-duplicated by id, + active SQLite rows authoritative — mirrors the existing collapse-by-id + invariant). `includeArchived` is intentionally left untouched here so it + keeps its narrow legacy meaning (an explicit search override) instead of + being repurposed to gate a full-archive refetch. + */ + const [archivedHasMore, setArchivedHasMore] = useState(false); + const [archivedLoadingMore, setArchivedLoadingMore] = useState(false); + const archivedOffsetRef = useRef(0); + // Note: archivedLoadedRef is declared earlier (near tasksRef) so refreshTasks can read it. + const archivedLoadingMoreRef = useRef(false); + /* + FNXC:ArchivePagination 2026-07-08-01:30: + Canonical store of every archived row merged in so far via + `loadArchivedTasks`/`loadMoreArchivedTasks`, independent of the transient + `tasks` state. A search-scoped `refreshTasks` fetch can temporarily + replace `tasks` with only the query-matched rows (active + matching + archived); if the generic-refresh carry-over in `refreshTasks` read + archived rows back out of `tasks` at that point, clearing the query would + "carry over" only the narrower search-result set and permanently lose any + previously loaded archived rows that did not match the last query. Keeping + a dedicated accumulator means carry-over always restores the full set of + archived rows loaded so far, regardless of what the last fetch's result + shape happened to be. + */ + const archivedTasksRef = useRef([]); + + const mergeArchivedPage = useCallback((page: Task[]) => { + const normalizedPage = page.map(normalizeTask); + const knownArchivedIds = new Set(archivedTasksRef.current.map((task) => task.id)); + const newArchived = normalizedPage.filter((task) => !knownArchivedIds.has(task.id)); + if (newArchived.length > 0) { + archivedTasksRef.current = [...archivedTasksRef.current, ...newArchived]; + } + setTasks((prev) => { + const existingIds = new Set(prev.map((task) => task.id)); + const additions = normalizedPage.filter((task) => !existingIds.has(task.id)); + if (additions.length === 0) return prev; + return [...prev, ...additions]; + }); }, []); + /** Lazy-load archived tasks, page 1 (100, newest-first). Called by the Board when the archived column is first expanded. */ + const loadArchivedTasks = useCallback(async () => { + if (archivedLoadedRef.current) return; + archivedLoadedRef.current = true; + try { + const { tasks: page, hasMore } = await api.fetchArchivedTasks(projectId, 100, 0); + mergeArchivedPage(page); + archivedOffsetRef.current = page.length; + setArchivedHasMore(hasMore); + } catch { + // Allow a future expand attempt to retry the first page. + archivedLoadedRef.current = false; + } + }, [projectId, mergeArchivedPage]); + + /** Fetch the next 100-item page of archived tasks. No-op when there is no further page or a fetch is already in flight. */ + const loadMoreArchivedTasks = useCallback(async () => { + if (!archivedLoadedRef.current || archivedLoadingMoreRef.current) return; + if (!archivedHasMore) return; + archivedLoadingMoreRef.current = true; + setArchivedLoadingMore(true); + try { + const { tasks: page, hasMore } = await api.fetchArchivedTasks(projectId, 100, archivedOffsetRef.current); + mergeArchivedPage(page); + archivedOffsetRef.current += page.length; + setArchivedHasMore(hasMore); + } finally { + archivedLoadingMoreRef.current = false; + setArchivedLoadingMore(false); + } + }, [projectId, archivedHasMore, mergeArchivedPage]); + // Debounced search effect - separate from refreshTasks to avoid dependency cycle const prevSearchQueryRef = useRef(searchQuery); useEffect(() => { @@ -300,6 +441,16 @@ export function useTasks(options?: UseTasksOptions) { useEffect(() => { setIsStale(true); void refreshTasks({ clearOnError: true }); + // FNXC:ArchivePagination 2026-07-08-00:00: reset archived-page state on + // project switch so a new project's Archived column starts collapsed + // and re-fetches its own page 1 rather than reusing the previous + // project's offset/hasMore. + archivedLoadedRef.current = false; + archivedOffsetRef.current = 0; + archivedLoadingMoreRef.current = false; + archivedTasksRef.current = []; + setArchivedHasMore(false); + setArchivedLoadingMore(false); const handleVisibilityChange = () => { if (document.visibilityState !== "visible") { @@ -823,5 +974,5 @@ export function useTasks(options?: UseTasksOptions) { lastFetchTimeMs.current = Date.now(); }, []); - return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current }; + return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, loadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current }; } diff --git a/packages/dashboard/app/test/mockApi.ts b/packages/dashboard/app/test/mockApi.ts index 8933314025..42404fdbc6 100644 --- a/packages/dashboard/app/test/mockApi.ts +++ b/packages/dashboard/app/test/mockApi.ts @@ -26,6 +26,8 @@ function getFallback(name: string): AnyFn { export const dashboardApiMocks: Record = { fetchTasks: vi.fn(async () => []), + // FNXC:ArchivePagination 2026-07-08-00:00: FN-7659 paged archived-tasks read. + fetchArchivedTasks: vi.fn(async () => ({ tasks: [], total: 0, hasMore: false })), fetchSettings: vi.fn(async () => ({})), fetchTaskEffectiveSettings: vi.fn().mockRejectedValue(new Error("fetchTaskEffectiveSettings: use fetchSettings mock")), updateSettings: vi.fn(async () => ({})), @@ -66,6 +68,7 @@ export async function createDashboardApiMock( export function resetDashboardApiMockState(): void { Object.values(dashboardApiMocks).forEach((fn) => fn.mockReset()); dashboardApiMocks.fetchTasks.mockResolvedValue([]); + dashboardApiMocks.fetchArchivedTasks.mockResolvedValue({ tasks: [], total: 0, hasMore: false }); dashboardApiMocks.fetchSettings.mockResolvedValue({}); dashboardApiMocks.fetchTaskEffectiveSettings.mockRejectedValue(new Error("fetchTaskEffectiveSettings: use fetchSettings mock")); dashboardApiMocks.updateSettings.mockResolvedValue({}); diff --git a/packages/dashboard/src/routes/__tests__/tasks-archived-pagination.test.ts b/packages/dashboard/src/routes/__tests__/tasks-archived-pagination.test.ts new file mode 100644 index 0000000000..a1aa7fa6f7 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/tasks-archived-pagination.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +// +// FN-7659: HTTP-level coverage for GET /tasks/archived — the dedicated +// paged archived-tasks read backing the Archived board column. Asserts +// newest-first ordering, LIMIT/OFFSET pagination windows, empty-archive +// shape, and 400 rejection for invalid params. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import { createApiRoutes } from "../../routes.js"; +import { request as REQUEST } from "../../test-request.js"; + +describe("GET /tasks/archived", () => { + let store: TaskStore; + let rootDir: string; + let globalDir: string; + let app: express.Express; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "tasks-archived-root-")); + globalDir = mkdtempSync(join(tmpdir(), "tasks-archived-global-")); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store, undefined)); + }); + + afterEach(() => { + store.close(); + rmSync(rootDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); + }); + + it("returns empty tasks/total 0/hasMore false for an empty archive", async () => { + const res = await REQUEST(app, "GET", "/api/tasks/archived"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ tasks: [], total: 0, hasMore: false }); + }); + + it("returns archived tasks newest-first (archivedAt DESC)", async () => { + const a = await store.createTask({ description: "archived-a" }); + const b = await store.createTask({ description: "archived-b" }); + const c = await store.createTask({ description: "archived-c" }); + // Archive in an order that diverges from createdAt to prove ordering + // is driven by archivedAt, not createdAt. + await store.archiveTask(c.id, true); + await store.archiveTask(a.id, true); + await store.archiveTask(b.id, true); + + const res = await REQUEST(app, "GET", "/api/tasks/archived?limit=100&offset=0"); + expect(res.status).toBe(200); + expect(res.body.total).toBe(3); + expect(res.body.hasMore).toBe(false); + expect(res.body.tasks.map((t: { id: string }) => t.id)).toEqual([b.id, a.id, c.id]); + }); + + it("paginates with LIMIT/OFFSET windows and correct hasMore transitions", async () => { + for (let i = 0; i < 5; i++) { + const task = await store.createTask({ description: `archive-window-${i}` }); + await store.archiveTask(task.id, true); + } + + const page1 = await REQUEST(app, "GET", "/api/tasks/archived?limit=2&offset=0"); + expect(page1.body.tasks).toHaveLength(2); + expect(page1.body.hasMore).toBe(true); + + const page2 = await REQUEST(app, "GET", "/api/tasks/archived?limit=2&offset=2"); + expect(page2.body.tasks).toHaveLength(2); + expect(page2.body.hasMore).toBe(true); + + const page3 = await REQUEST(app, "GET", "/api/tasks/archived?limit=2&offset=4"); + expect(page3.body.tasks).toHaveLength(1); + expect(page3.body.hasMore).toBe(false); + }); + + it("rejects invalid limit/offset with 400", async () => { + const badLimit = await REQUEST(app, "GET", "/api/tasks/archived?limit=0"); + expect(badLimit.status).toBe(400); + + const negativeLimit = await REQUEST(app, "GET", "/api/tasks/archived?limit=-5"); + expect(negativeLimit.status).toBe(400); + + const negativeOffset = await REQUEST(app, "GET", "/api/tasks/archived?offset=-1"); + expect(negativeOffset.status).toBe(400); + + const nonNumericLimit = await REQUEST(app, "GET", "/api/tasks/archived?limit=abc"); + expect(nonNumericLimit.status).toBe(400); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 47c64836c6..28ae9d5cfe 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -980,6 +980,38 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + /** + * FNXC:ArchivePagination 2026-07-08-00:00: + * Dedicated paged read for the Archived board column: newest-first + * (`archivedAt DESC`) in chunks of 100 by default via SQL LIMIT/OFFSET, + * so a large archive is never loaded into memory in one pass. This is a + * sibling to GET /tasks (which stays byte-identical for its existing + * merged-listing consumers) rather than a replacement for it. + */ + router.get("/tasks/archived", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + 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)) { + throw badRequest("limit must be a positive integer"); + } + if (offset !== undefined && (!Number.isFinite(offset) || offset < 0)) { + throw badRequest("offset must be a non-negative integer"); + } + + const { tasks, total, hasMore } = await scopedStore.listArchivedTasks({ limit, offset, slim: true }); + + res.json({ tasks, total, hasMore }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + router.post("/tasks/duplicate-check", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req);