From f7bf3f91d14613a027df227d428a1a52e75605c5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 12 Aug 2026 22:18:18 -0700 Subject: [PATCH] FN-9022: add task recommendations to Insights Expose actionable task recommendations in the Insights view. - Add recommendation list API, client hook, and Insights rendering states. - Localize recommendation content and document the Insights workflow. - Cover recommendation retrieval, routes, hooks, API, and view behavior. Files changed: .changeset/fn-9022-insights-recommendations.md | 7 ++ docs/architecture.md | 1 + docs/dashboard-guide.md | 3 +- .../__tests__/task-recommendations-list.test.ts | 136 +++++++++++++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/store.ts | 5 +- packages/core/src/task-store/reads.ts | 48 +++++++- packages/core/src/types.ts | 4 + packages/core/src/types/task/task-core.ts | 19 +++ packages/dashboard/app/__tests__/api-tasks.test.ts | 24 ++++ packages/dashboard/app/api/legacy.ts | 3 + packages/dashboard/app/api/tasks/tasks.ts | 22 ++++ packages/dashboard/app/components/InsightsView.css | 27 ++++ packages/dashboard/app/components/InsightsView.tsx | 47 +++++-- .../app/components/__tests__/InsightsView.test.tsx | 117 ++++++++++++++++++ .../hooks/__tests__/useTaskRecommendations.test.ts | 130 ++++++++++++++++++++ .../dashboard/app/hooks/useTaskRecommendations.ts | 116 ++++++++++++++++++ .../__tests__/task-recommendation-routes.test.ts | 26 ++++ .../src/routes/register-task-workflow-routes.ts | 33 +++++ packages/i18n/locales/en/app.json | 11 +- packages/i18n/locales/es/app.json | 11 +- packages/i18n/locales/fr/app.json | 11 +- packages/i18n/locales/ko/app.json | 11 +- packages/i18n/locales/pt-BR/app.json | 11 +- packages/i18n/locales/zh-CN/app.json | 11 +- packages/i18n/locales/zh-TW/app.json | 11 +- packages/i18n/src/resources.d.ts | 9 ++ 27 files changed, 835 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-9022 Fusion-Task-Lineage: b83f1074-37af-4f87-9ec0-2bd9bb9bf64d Co-authored-by: Fusion (runfusion.ai) --- .../fn-9022-insights-recommendations.md | 7 + docs/architecture.md | 1 + docs/dashboard-guide.md | 3 +- .../task-recommendations-list.test.ts | 136 ++++++++++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/store.ts | 5 +- packages/core/src/task-store/reads.ts | 48 ++++++- packages/core/src/types.ts | 4 + packages/core/src/types/task/task-core.ts | 19 +++ .../dashboard/app/__tests__/api-tasks.test.ts | 24 ++++ packages/dashboard/app/api/legacy.ts | 3 + packages/dashboard/app/api/tasks/tasks.ts | 22 +++ .../dashboard/app/components/InsightsView.css | 27 ++++ .../dashboard/app/components/InsightsView.tsx | 47 ++++-- .../__tests__/InsightsView.test.tsx | 117 +++++++++++++++ .../__tests__/useTaskRecommendations.test.ts | 130 +++++++++++++++++ .../app/hooks/useTaskRecommendations.ts | 116 +++++++++++++++ .../task-recommendation-routes.test.ts | 26 ++++ .../routes/register-task-workflow-routes.ts | 33 +++++ packages/i18n/locales/en/app.json | 11 +- packages/i18n/locales/es/app.json | 11 +- packages/i18n/locales/fr/app.json | 11 +- packages/i18n/locales/ko/app.json | 11 +- packages/i18n/locales/pt-BR/app.json | 11 +- packages/i18n/locales/zh-CN/app.json | 11 +- packages/i18n/locales/zh-TW/app.json | 11 +- packages/i18n/src/resources.d.ts | 9 ++ 27 files changed, 835 insertions(+), 21 deletions(-) create mode 100644 .changeset/fn-9022-insights-recommendations.md create mode 100644 packages/core/src/__tests__/task-recommendations-list.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useTaskRecommendations.test.ts create mode 100644 packages/dashboard/app/hooks/useTaskRecommendations.ts diff --git a/.changeset/fn-9022-insights-recommendations.md b/.changeset/fn-9022-insights-recommendations.md new file mode 100644 index 0000000000..01c6e5d02e --- /dev/null +++ b/.changeset/fn-9022-insights-recommendations.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add project-wide task recommendations to Insights. +category: feature +dev: Adds a bounded row-paginated task recommendations API and dashboard triage surface. diff --git a/docs/architecture.md b/docs/architecture.md index 229790e576..253f16470f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2023,6 +2023,7 @@ Fusion adds an operator-first API surface to diagnose and expedite refinement ta | Method | Path | Description | |---|---|---| +| GET | `/api/tasks/recommendations` | List completed-task recommendations scoped to complete-role columns. Row-paginated with `limit`/`offset` (maximum 200) and returns `hasMore` plus `totalRowCount`. | | GET | `/api/tasks/stranded-refinements` | List stranded refinement diagnostics (`sourceType=task_refine`, `column=triage`, `paused!=true`) with reasons and recommendation. Supports `?freshnessMinutes=` (1-1440). | | GET | `/api/tasks/:id/stranded-refinement` | Return one refinement diagnostic row plus PROMPT.md presence and dependency-resolution status. | | POST | `/api/tasks/:id/expedite-refinement` | Request bounded expedite for a triage refinement. Clears `nextRecoveryAt` for stale/backoff rows; returns `requiresOperatorAction` for `awaiting-approval`/`failed`/`stuck-killed` without mutating status. | diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 46e1a7da38..c5120bb841 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1337,6 +1337,7 @@ Features: - The model gear beside **Generate Insights** opens a model picker with an inline **Thinking Level** selector. Both the model override and reasoning-effort choice persist in the browser, and each insight run records the selected reasoning effort so retries reuse the same setting. - Dismiss/archive/unarchive insight records as they age - Create triage tasks from selected insights directly from the view +- **Task Recommendations** aggregates un-actioned follow-up suggestions from completed tasks. It loads 50 completed source-task rows at a time; **Load more** is explicit and remains available while more rows exist, up to 20 pages, after which a truncation notice is shown. The section remains visible even when there are no generated insights, while no captured recommendations leaves no empty category. Both this surface and a task detail Recommendations tab use the same guarded create-task endpoint. ## Command Center @@ -1499,7 +1500,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the task's effective planning model, but opening the tab is lookup-only: Fusion creates the task-scoped planner chat only after you send a composer message, starter prompt, or planner-question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control. - The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed. - The **Summary** tab appears for `done` tasks and remains their default landing tab. It shows the recorded completion summary, the **Merge Details** card (merge status, commit, PR, timestamp, and message), changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. -- The **Recommendations** tab appears on a completed task only when at least one recommendation was captured. At accepted completion, executors evaluate optional, non-blocking out-of-scope findings and submit task-ready recommendations; an explicit `[]` means none qualified, not that filler should be invented. The project cap bounds captured results, and `maxRecommendationsPerTask: 0` disables capture and therefore removes the tab entirely. A non-empty set sends one mailbox notice per distinct recommendation-id set only after completion is accepted; interrupted or rolled-back handoffs and linking an already-captured recommendation to a created task send nothing. Delivery is asynchronous and best-effort, so it never delays task completion; **Settings → General → Recommendation mailbox notices** can disable only this notice, not capture. Recommendations are distinct from immediately created/delegated tasks, which remain appropriate for required dependency coordination, explicit task requirements, or operator-directed filing. An empty result surfaces as no tab; otherwise each row shows a task-ready title, category, and description. **Create task** uses the normal guarded intake policy (including duplicate checks), so a duplicate conflict creates no child and leaves the recommendation available to retry; successful repeated clicks reuse the same linked triage task. +- The **Recommendations** tab appears on a completed task only when at least one recommendation was captured. At accepted completion, executors evaluate optional, non-blocking out-of-scope findings and submit task-ready recommendations; an explicit `[]` means none qualified, not that filler should be invented. The project cap bounds captured results, and `maxRecommendationsPerTask: 0` disables capture and therefore removes the tab entirely. A non-empty set sends one mailbox notice per distinct recommendation-id set only after completion is accepted; interrupted or rolled-back handoffs and linking an already-captured recommendation to a created task send nothing. Delivery is asynchronous and best-effort, so it never delays task completion; **Settings → General → Recommendation mailbox notices** can disable only this notice, not capture. Recommendations are distinct from immediately created/delegated tasks, which remain appropriate for required dependency coordination, explicit task requirements, or operator-directed filing. An empty result surfaces as no tab; otherwise each row shows a task-ready title, category, and description. **Create task** uses the normal guarded intake policy (including duplicate checks), so a duplicate conflict creates no child and leaves the recommendation available to retry; successful repeated clicks reuse the same linked triage task. The same recommendations also appear project-wide in **Insights → Task Recommendations**, where row pagination and an explicit **Load more** control keep the aggregate bounded without hiding later suggestions. - The **Cost** tab is available for tasks in every column and sits immediately after **Comments → Terminal** in the tab strip. It shows the read-time derived per-model cost breakdown (input, output, cached, cache-write, total tokens, derived USD) and a task total; no token usage shows an explicit empty state, while unpriced or zero-usage rows use `—` instead of a guessed `$0`. - The **Models** tab exposes inline **Thinking Level** selectors for **Executor Model**, **Reviewer Model**, and **Planning Model**. Executor saves the shared task thinking level, while Reviewer and Planning save independent per-lane overrides; leaving either lane on **Default** inherits the shared task thinking level and then the configured workflow/project defaults. diff --git a/packages/core/src/__tests__/task-recommendations-list.test.ts b/packages/core/src/__tests__/task-recommendations-list.test.ts new file mode 100644 index 0000000000..ae3aef4319 --- /dev/null +++ b/packages/core/src/__tests__/task-recommendations-list.test.ts @@ -0,0 +1,136 @@ +/* +FNXC:TaskRecommendations 2026-08-13-04:59: +The aggregate Insights read pages completed task rows, not individual JSONB entries. Exercise the +production PostgreSQL store boundary so SQL-side empty-row filtering, stable row ordering, and the +page metadata cannot drift from the dashboard paging contract. +*/ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../__test-utils__/pg-test-harness.js"; +import { applySchemaBaseline } from "../postgres/schema-applier.js"; +import { insertTaskRow } from "../task-store/async/async-persistence.js"; + +function recommendation(id: string) { + return { + id, + title: `Follow up ${id}`, + description: `Track the separately scoped follow-up ${id}.`, + category: "improvement" as const, + }; +} + +async function insertRecommendationTask( + h: SharedPgTaskStoreHarness, + id: string, + overrides: Record = {}, +): Promise { + await insertTaskRow(h.layer(), { + id, + title: `Source ${id}`, + description: `Completed source task ${id}.`, + column: "complete", + currentStep: 0, + createdAt: "2026-08-13T00:00:00.000Z", + updatedAt: "2026-08-13T00:00:00.000Z", + recommendations: [recommendation(`rec-${id}`)], + ...overrides, + }, { lineageId: `lineage-${id}` }); +} + +pgDescribe("TaskStore.listTaskRecommendations", () => { + const h = createSharedPgTaskStoreTestHarness({ prefix: "fusion_task_recommendations_list" }); + + beforeAll(async () => { + await h.beforeAll(); + await applySchemaBaseline(h.adminDb()); + }); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + afterAll(h.afterAll); + + it("filters to live completed non-empty source rows and pages them in stable updatedAt/id order", async () => { + await insertRecommendationTask(h, "FN-A", { + updatedAt: "2026-08-13T01:00:00.000Z", + recommendations: [recommendation("first-a"), recommendation("second-a")], + }); + await insertRecommendationTask(h, "FN-Z", { + updatedAt: "2026-08-13T01:00:00.000Z", + }); + await insertRecommendationTask(h, "FN-NEW", { + updatedAt: "2026-08-13T02:00:00.000Z", + }); + await insertRecommendationTask(h, "FN-EMPTY", { + updatedAt: "2026-08-13T03:00:00.000Z", + recommendations: [], + }); + await insertRecommendationTask(h, "FN-TODO", { + column: "todo", + updatedAt: "2026-08-13T04:00:00.000Z", + }); + await insertRecommendationTask(h, "FN-DELETED", { + deletedAt: "2026-08-13T05:00:00.000Z", + updatedAt: "2026-08-13T05:00:00.000Z", + }); + + const firstPage = await h.store().listTaskRecommendations({ + completeColumns: new Set(["complete"]), + limit: 2, + offset: 0, + }); + expect(firstPage).toMatchObject({ + rowOffset: 0, + rowLimit: 2, + returnedRowCount: 2, + totalRowCount: 3, + hasMore: true, + }); + expect(firstPage.items.map((item) => [item.taskId, item.recommendation.id])).toEqual([ + ["FN-NEW", "rec-FN-NEW"], + ["FN-Z", "rec-FN-Z"], + ]); + + const finalPage = await h.store().listTaskRecommendations({ + completeColumns: new Set(["complete"]), + limit: 2, + offset: firstPage.rowOffset + firstPage.returnedRowCount, + }); + expect(finalPage).toMatchObject({ + rowOffset: 2, + rowLimit: 2, + returnedRowCount: 1, + totalRowCount: 3, + hasMore: false, + }); + expect(finalPage.items.map((item) => [item.taskId, item.recommendation.id])).toEqual([ + ["FN-A", "first-a"], + ["FN-A", "second-a"], + ]); + }); + + it("uses the default row limit and clamps oversized row pages", async () => { + await insertRecommendationTask(h, "FN-LIMIT"); + + await expect(h.store().listTaskRecommendations({ + completeColumns: new Set(["complete"]), + })).resolves.toMatchObject({ + rowOffset: 0, + rowLimit: 50, + returnedRowCount: 1, + totalRowCount: 1, + hasMore: false, + }); + await expect(h.store().listTaskRecommendations({ + completeColumns: new Set(["complete"]), + limit: 999, + })).resolves.toMatchObject({ + rowOffset: 0, + rowLimit: 200, + returnedRowCount: 1, + totalRowCount: 1, + hasMore: false, + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8277bf2af7..2d1b0fb78c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -221,7 +221,7 @@ export { } from "./agents/agent-memory-mode.js"; export type { TaskReviewData, TaskReviewSummary, TaskReviewItem, TaskReviewVerdict, TaskReviewerType } from "./types.js"; /* FNXC:TaskVerificationRequest 2026-07-30-00:00: FN-8296 makes the persisted verification read model available to dashboard task and Command Center surfaces without exporting a subprocess runner. */ -export type { TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, TaskVerificationProfile, TaskRecommendation, TaskRecommendationCategory } from "./types.js"; +export type { TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, TaskVerificationProfile, TaskRecommendation, TaskRecommendationCategory, TaskRecommendationListItem, TaskRecommendationListPage } from "./types.js"; export type { TaskCommitAssociation, TaskCommitAssociationConfidence, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 96468daaf6..790bb2d92c 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -150,7 +150,7 @@ import { deleteTaskImpl, archiveTaskImpl, type DeleteTaskIfResult } from "./task import type { TaskDeleteAuditContext } from "./task-delete-attribution.js"; import { updateSettingsImpl, updateGlobalSettingsImpl } from "./task-store/settings-ops.js"; import { createTaskBackendImpl, _createTaskInternalBackendImpl, createTaskImpl, createTaskWithReservedIdImpl, _createTaskInternalImpl, _maybeAutoArchiveSameAgentDuplicateImpl } from "./task-store/task-creation.js"; -import { getTaskImpl, listTasksImpl, searchTasksImpl, listTasksModifiedSinceImpl, getTaskVerificationRequestAsyncImpl } from "./task-store/reads.js"; +import { getTaskImpl, listTasksImpl, searchTasksImpl, listTasksModifiedSinceImpl, getTaskVerificationRequestAsyncImpl, listTaskRecommendationsImpl } from "./task-store/reads.js"; import { updateTaskUnlockedImpl } from "./task-store/task-update.js"; import { __setTaskActivityLogLimitsForTesting } from "./task-store/comments.js"; import { declaresAnyLifecycleTrait, resolveReviewColumns, resolveTaskLifecycleColumns, type LifecycleColumns } from "./workflows/workflow-lifecycle-traits.js"; @@ -1595,6 +1595,9 @@ export class TaskStore extends EventEmitter { async clearWorkflowRunStepInstancesAsync(taskId: string, keepRunId?: string): Promise { return clearWorkflowRunStepInstancesAsyncImpl(this, taskId, keepRunId); } + async listTaskRecommendations(options?: { completeColumns?: ReadonlySet; limit?: number; offset?: number }): Promise { + return listTaskRecommendationsImpl(this, options); + } async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> { return listTasksForGithubTrackingReconcileImpl(this, options); } diff --git a/packages/core/src/task-store/reads.ts b/packages/core/src/task-store/reads.ts index cdfa2661d9..73bbed1c85 100644 --- a/packages/core/src/task-store/reads.ts +++ b/packages/core/src/task-store/reads.ts @@ -10,9 +10,9 @@ import {TaskStore, storeLog} from "../store.js"; import {readFile} from "node:fs/promises"; import {join} from "node:path"; import {existsSync, statSync} from "node:fs"; -import type {Task, TaskDetail, ColumnId, ArchivedTaskEntry, TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus} from "../types.js"; +import type {Task, TaskDetail, ColumnId, ArchivedTaskEntry, TaskVerificationRequest, TaskVerificationResultSummary, TaskVerificationStatus, TaskRecommendation, TaskRecommendationListItem, TaskRecommendationListPage} from "../types.js"; import * as schema from "../postgres/schema/index.js"; -import { and, eq } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; import "../builtin-traits.js"; import {allowsAutoMergeProcessing} from "../merge/task-merge.js"; import {getInReviewStallReason, DEFAULT_STALE_MERGING_MIN_AGE_MS, type InReviewStallContext} from "../tasks/in-review-stall.js"; @@ -32,6 +32,7 @@ import {computeRetrySummary} from "../tasks/retry-summary.js"; // answer 404 instead of 500 (see TaskNotFoundError in task-store/errors.ts). import {TaskNotFoundError} from "../task-store/errors.js"; import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.js"; +import { taskProjectScope } from "../postgres/data-layer.js"; /** Merge storage tiers while preserving primary-source authority and order. */ function mergePrimaryById(primary: T[], secondary: T[]): T[] { @@ -908,3 +909,46 @@ export async function getTaskVerificationRequestAsyncImpl(store: TaskStore, task const row = rows[0]; return row ? { taskId: row.taskId, requestId: row.requestId, status: row.status as TaskVerificationStatus, profile: row.profile as TaskVerificationRequest["profile"], command: row.command, scope: row.scope as TaskVerificationRequest["scope"], requestedBy: row.requestedBy, requestedAt: row.requestedAt, ...(row.startedAt ? { startedAt: row.startedAt } : {}), ...(row.completedAt ? { completedAt: row.completedAt } : {}), ...(row.result ? { result: row.result as TaskVerificationResultSummary } : {}), ...(row.rejectionReason ? { rejectionReason: row.rejectionReason } : {}) } : null; } + +/** + * FNXC:TaskRecommendations 2026-08-13-04:41: + * Insights needs a dedicated narrow read because its aggregate is advisory triage and must remain + * bounded. Rows, rather than JSONB items, are paged with updatedAt/id ordering so equal completion + * timestamps cannot drop or duplicate a row; offset paging is sufficient because creating a task + * links the recommendation without removing its source row. + */ +export async function listTaskRecommendationsImpl( + store: TaskStore, + options?: { completeColumns?: ReadonlySet; limit?: number; offset?: number }, +): Promise { + const layer = store.asyncLayer!; + const completeColumns = options?.completeColumns ?? await resolveProjectColumnsForRoles(store, ["complete"]); + const rawLimit = options?.limit; + const rawOffset = options?.offset; + const limit = typeof rawLimit === "number" && Number.isFinite(rawLimit) ? Math.min(200, Math.max(1, Math.trunc(rawLimit))) : 50; + const offset = typeof rawOffset === "number" && Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0; + const columns = [...completeColumns]; + if (columns.length === 0) return { items: [], rowOffset: offset, rowLimit: limit, returnedRowCount: 0, totalRowCount: 0, hasMore: false }; + const filter = and( + taskProjectScope(layer), + isNull(schema.project.tasks.deletedAt), + inArray(schema.project.tasks.column, columns), + isNotNull(schema.project.tasks.recommendations), + sql`jsonb_array_length(${schema.project.tasks.recommendations}) > 0`, + ); + const [countRows, rows] = await Promise.all([ + layer.db.select({ count: sql`count(*)` }).from(schema.project.tasks).where(filter), + layer.db.select({ id: schema.project.tasks.id, title: schema.project.tasks.title, column: schema.project.tasks.column, updatedAt: schema.project.tasks.updatedAt, recommendations: schema.project.tasks.recommendations }) + .from(schema.project.tasks).where(filter).orderBy(desc(schema.project.tasks.updatedAt), desc(schema.project.tasks.id)).limit(limit).offset(offset), + ]); + const items: TaskRecommendationListItem[] = []; + for (const row of rows) { + if (!Array.isArray(row.recommendations) || row.recommendations.length === 0) continue; + for (const recommendation of row.recommendations) { + items.push({ taskId: row.id, taskTitle: row.title ?? undefined, taskColumn: row.column, updatedAt: row.updatedAt, recommendation: recommendation as TaskRecommendation }); + } + } + const totalRowCount = Number(countRows[0]?.count ?? 0); + const returnedRowCount = rows.length; + return { items, rowOffset: offset, rowLimit: limit, returnedRowCount, totalRowCount, hasMore: offset + returnedRowCount < totalRowCount }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 576615be10..14c52b0f6e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -608,6 +608,8 @@ import type { TaskWedgeNotificationState, TaskRecommendation, TaskRecommendationCategory, + TaskRecommendationListItem, + TaskRecommendationListPage, Task, TaskReleaseGateVerdict, TaskVerificationResultSummary, @@ -653,6 +655,8 @@ export type { TaskWedgeNotificationState, TaskRecommendation, TaskRecommendationCategory, + TaskRecommendationListItem, + TaskRecommendationListPage, Task, TaskReleaseGateVerdict, TaskVerificationResultSummary, diff --git a/packages/core/src/types/task/task-core.ts b/packages/core/src/types/task/task-core.ts index 85f30a9585..7626f125b0 100644 --- a/packages/core/src/types/task/task-core.ts +++ b/packages/core/src/types/task/task-core.ts @@ -643,6 +643,25 @@ export interface TaskRecommendation { createdTaskId?: string; } +/** A recommendation paired with the completed task that produced it. */ +export interface TaskRecommendationListItem { + taskId: string; + taskTitle?: string; + taskColumn: string; + updatedAt: string; + recommendation: TaskRecommendation; +} + +/** Row-paginated aggregate recommendation response for advisory project triage. */ +export interface TaskRecommendationListPage { + items: TaskRecommendationListItem[]; + rowOffset: number; + rowLimit: number; + returnedRowCount: number; + totalRowCount: number; + hasMore: boolean; +} + export interface TaskReleaseGateVerdict { promoteBlocked: boolean; unplannedForExecution: boolean; diff --git a/packages/dashboard/app/__tests__/api-tasks.test.ts b/packages/dashboard/app/__tests__/api-tasks.test.ts index 41aacc8a5c..6ef216851b 100644 --- a/packages/dashboard/app/__tests__/api-tasks.test.ts +++ b/packages/dashboard/app/__tests__/api-tasks.test.ts @@ -11,6 +11,7 @@ import { updateTask, createTask, createTaskFromRecommendation, + fetchTaskRecommendations, connectPlanningStream, connectSubtaskStream, connectMissionInterviewStream, @@ -623,6 +624,29 @@ describe("createTaskFromRecommendation", () => { }); }); +describe("fetchTaskRecommendations", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("forwards optional row paging and preserves the aggregate envelope", async () => { + const page = { items: [], rowOffset: 50, rowLimit: 50, returnedRowCount: 2, totalRowCount: 52, hasMore: false }; + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, page)); + + await expect(fetchTaskRecommendations("project-a", { limit: 50, offset: 50 })).resolves.toEqual(page); + expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/recommendations?limit=50&offset=50&projectId=project-a", { headers: API_JSON_HEADERS }); + }); + + it("omits absent paging options without sending a request body", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { items: [], rowOffset: 0, rowLimit: 50, returnedRowCount: 0, totalRowCount: 0, hasMore: false })); + + await fetchTaskRecommendations("project-a"); + expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/recommendations?projectId=project-a", { headers: API_JSON_HEADERS }); + }); +}); + describe("createTask", () => { const originalFetch = globalThis.fetch; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index eef2273cc5..c4c6b1664c 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -35,6 +35,7 @@ export type { export { fetchTasks, fetchArchivedTasks, + fetchTaskRecommendations, fetchTaskDetail, fetchTaskPrompt, fetchTaskRuntimeFallback, @@ -50,6 +51,7 @@ export { import type { DeleteTaskOptions, ArchiveTaskOptions, + TaskRecommendationsResponse, TaskRuntimeFallbackResponse, TaskPromptResponse, UpdateTaskReviewRequest, @@ -67,6 +69,7 @@ import type { export type { DeleteTaskOptions, ArchiveTaskOptions, + TaskRecommendationsResponse, TaskRuntimeFallbackResponse, TaskPromptResponse, UpdateTaskReviewRequest, diff --git a/packages/dashboard/app/api/tasks/tasks.ts b/packages/dashboard/app/api/tasks/tasks.ts index 4a7259c0e8..1138e277d7 100644 --- a/packages/dashboard/app/api/tasks/tasks.ts +++ b/packages/dashboard/app/api/tasks/tasks.ts @@ -15,6 +15,7 @@ import type { CurrentPlanEvidence, DriftReport, SpecLock, + TaskRecommendationListItem, } from "@fusion/core"; import { withTokenHeader } from "../../auth"; import { api, ApiRequestError, buildApiUrl, proxyApi } from "../client/client.js"; @@ -68,6 +69,27 @@ export function fetchArchivedTasks( return api<{ tasks: Task[]; total: number; hasMore: boolean }>(withProjectId(`/tasks/archived${suffix}`, projectId)); } +/** Row-paginated recommendation aggregate returned by the Insights triage route. */ +export interface TaskRecommendationsResponse { + items: TaskRecommendationListItem[]; + rowOffset: number; + rowLimit: number; + returnedRowCount: number; + totalRowCount: number; + hasMore: boolean; +} + +export function fetchTaskRecommendations( + projectId?: string, + options?: { limit?: number; offset?: number }, +): Promise { + const query = new URLSearchParams(); + if (options?.limit !== undefined) query.set("limit", String(options.limit)); + if (options?.offset !== undefined) query.set("offset", String(options.offset)); + const suffix = query.size > 0 ? `?${query.toString()}` : ""; + return api(withProjectId(`/tasks/recommendations${suffix}`, projectId)); +} + /** A Definition refresh payload deliberately excludes mutable card state. */ export interface TaskPromptResponse { id: string; diff --git a/packages/dashboard/app/components/InsightsView.css b/packages/dashboard/app/components/InsightsView.css index 2a1140e020..057c45bad2 100644 --- a/packages/dashboard/app/components/InsightsView.css +++ b/packages/dashboard/app/components/InsightsView.css @@ -671,3 +671,30 @@ Insights tablets at 769px–1024px were inheriting the desktop fixed category si overflow-y: auto; } } + +/* +FNXC:TaskRecommendations 2026-08-13-04:41: +The Insights recommendation pane keeps source identity and actions inside the established list layout, +so desktop and horizontal mobile category rails expose the same reachable Load more control. +*/ +.insights-recommendations__count, +.insights-recommendations__source { + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.insights-recommendations__item { + overflow-wrap: anywhere; +} + +@media (max-width: 768px), (max-height: 480px) { + .insights-recommendations .btn { + inline-size: 100%; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .insights-recommendations__item { + min-inline-size: 0; + } +} diff --git a/packages/dashboard/app/components/InsightsView.tsx b/packages/dashboard/app/components/InsightsView.tsx index 33eeb69024..15b9faf762 100644 --- a/packages/dashboard/app/components/InsightsView.tsx +++ b/packages/dashboard/app/components/InsightsView.tsx @@ -31,6 +31,7 @@ import { ViewHeader } from "./ViewHeader"; import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag"; import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api"; import { useInsights, type InsightSection } from "../hooks/useInsights"; +import { useTaskRecommendations } from "../hooks/useTaskRecommendations"; import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter"; import type { InsightCategory } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; @@ -85,6 +86,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model archivedCount = 0, showArchived = false, } = useInsights(projectId); + const taskRecommendations = useTaskRecommendations(projectId); const [statusMessage, setStatusMessage] = useState(null); const [statusType, setStatusType] = useState<"success" | "error" | "info">("info"); @@ -202,22 +204,22 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model .filter((section) => section.items.length > 0); }, [populatedSections, backlogHealthOnly]); - const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); // Keep selection valid as data changes; default to first populated section. useEffect(() => { + if (selectedCategory === "recommendations" && taskRecommendations.items.length > 0) return; if (filteredSections.length === 0) { - if (selectedCategory !== null) setSelectedCategory(null); + if (taskRecommendations.items.length > 0) setSelectedCategory("recommendations"); + else if (selectedCategory !== null) setSelectedCategory(null); return; } const stillExists = selectedCategory && filteredSections.some((s) => s.category === selectedCategory && s.items.length > 0); - if (!stillExists) { - setSelectedCategory(filteredSections[0].category); - } - }, [filteredSections, selectedCategory]); + if (!stillExists) setSelectedCategory(filteredSections[0]?.category ?? (taskRecommendations.items.length > 0 ? "recommendations" : null)); + }, [filteredSections, selectedCategory, taskRecommendations.items.length]); const activeSection: InsightSection | undefined = useMemo( - () => filteredSections.find((s) => s.category === selectedCategory) ?? filteredSections[0], + () => selectedCategory === "recommendations" ? undefined : filteredSections.find((s) => s.category === selectedCategory) ?? filteredSections[0], [filteredSections, selectedCategory], ); @@ -376,6 +378,23 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model ); }; + const renderRecommendations = () => ( +
+

{t("insights.recommendations.title", "Task Recommendations")}

{taskRecommendations.items.length}
+

{t("insights.recommendations.count", "Showing {{shown}} of {{total}} source tasks", { shown: taskRecommendations.items.length, total: taskRecommendations.totalRowCount })}

+
    + {taskRecommendations.items.map((item) => { + const key = `${item.taskId}:${item.recommendation.id}`; + const action = taskRecommendations.createStates.get(key); + const createdTaskId = item.recommendation.createdTaskId; + return
  • {item.recommendation.title}

    {item.recommendation.category}

    {item.recommendation.description}

    {t("insights.recommendations.source", "Source: {{task}}", { task: item.taskTitle ? `${item.taskId} — ${item.taskTitle}` : item.taskId })}

    {createdTaskId ? {t("taskDetail.recommendations.created", "Created {{taskId}}", { taskId: createdTaskId })} :
    {action?.error && {t("taskDetail.recommendations.error", "Could not create task. Try again.")}}
    }
  • ; + })} +
+ {taskRecommendations.truncated ?

{t("insights.recommendations.truncated", "Showing the first 20 pages. Refresh to see the latest recommendations.")}

: taskRecommendations.hasMore ? : null} + {taskRecommendations.error && !taskRecommendations.loading &&
{t("insights.recommendations.loadMoreFailed", "Could not load more recommendations.")}
} +
+ ); + const renderActiveInsights = () => { if (!activeSection) return null; const IconComponent = CATEGORY_ICONS[activeSection.category] ?? Sparkles; @@ -661,6 +680,15 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model )} + {/* FNXC:TaskRecommendations 2026-08-13-04:41: Initial aggregate-read failures need an in-view retry; otherwise an empty Insights state makes the new triage surface unreachable. */} + {taskRecommendations.error && !taskRecommendations.loading && taskRecommendations.items.length === 0 && ( +
+ + {t("insights.recommendations.loadMoreFailed", "Could not load more recommendations.")} + +
+ )} + {loading ? (
@@ -674,7 +702,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model {t("actions.retry", "Retry")}
- ) : totalCount === 0 ? ( + ) : totalCount === 0 && taskRecommendations.items.length === 0 ? (

{t("insights.noInsightsYet", "No insights yet")}

@@ -689,10 +717,11 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
- {renderActiveInsights()} + {selectedCategory === "recommendations" ? renderRecommendations() : renderActiveInsights()}
)} diff --git a/packages/dashboard/app/components/__tests__/InsightsView.test.tsx b/packages/dashboard/app/components/__tests__/InsightsView.test.tsx index d63da493d2..118821dec0 100644 --- a/packages/dashboard/app/components/__tests__/InsightsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/InsightsView.test.tsx @@ -20,6 +20,10 @@ vi.mock("../../hooks/useInsights", () => ({ }, })); +vi.mock("../../hooks/useTaskRecommendations", () => ({ + useTaskRecommendations: vi.fn(), +})); + // Mock lucide-react icons vi.mock("lucide-react", () => ({ Sparkles: ({ size = 24, className = "" }: { size?: number; className?: string }) => ( @@ -79,8 +83,10 @@ vi.mock("lucide-react", () => ({ })); import { useInsights } from "../../hooks/useInsights"; +import { useTaskRecommendations } from "../../hooks/useTaskRecommendations"; const mockUseInsights = vi.mocked(useInsights); +const mockUseTaskRecommendations = vi.mocked(useTaskRecommendations); describe("InsightsView", () => { const defaultProps = { @@ -99,6 +105,19 @@ describe("InsightsView", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseTaskRecommendations.mockReturnValue({ + items: [], + loading: false, + loadingMore: false, + error: null, + hasMore: false, + totalRowCount: 0, + truncated: false, + refresh: vi.fn(), + loadMore: vi.fn(), + createTask: vi.fn(), + createStates: new Map(), + }); mockUseInsights.mockReturnValue({ sections: mockSections, loading: false, @@ -1459,4 +1478,102 @@ describe("InsightsView", () => { expect(css).toMatch(/@media[^{]*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)[^{]*\{[\s\S]*?\.insights-detail\s*\{[^}]*flex:\s*1\s+1\s+0;[^}]*min-width:\s*0;[^}]*min-inline-size:\s*0;[^}]*overflow-y:\s*auto;[^}]*\}/); }); }); + + describe("task recommendations", () => { + const recommendations = [ + { + taskId: "FN-1", + taskTitle: "First source", + recommendation: { id: "shared-id", title: "First follow-up", description: "First description", category: "improvement" }, + }, + { + taskId: "FN-2", + taskTitle: "Second source", + recommendation: { id: "shared-id", title: "Second follow-up", description: "Second description", category: "bug" }, + }, + ]; + + it("keeps recommendations reachable when insights are empty and routes creates by the composite key", async () => { + const createTask = vi.fn(); + mockUseTaskRecommendations.mockReturnValue({ + items: recommendations, + loading: false, + loadingMore: false, + error: null, + hasMore: true, + totalRowCount: 3, + truncated: false, + refresh: vi.fn(), + loadMore: vi.fn(), + createTask, + createStates: new Map(), + }); + + render(); + + await waitFor(() => expect(screen.getByTestId("insights-category-recommendations")).toBeInTheDocument()); + expect(screen.queryByTestId("insights-empty")).not.toBeInTheDocument(); + expect(screen.getByTestId("insights-section-recommendations")).toBeInTheDocument(); + expect(screen.getByTestId("task-recommendation-FN-1:shared-id")).toBeInTheDocument(); + expect(screen.getByTestId("task-recommendation-FN-2:shared-id")).toBeInTheDocument(); + expect(screen.getByText("Showing 2 of 3 source tasks")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("button", { name: "Create task" })[1]!); + expect(createTask).toHaveBeenCalledWith("FN-2", "shared-id"); + }); + + it("does not offer creates for linked recommendations and preserves retryable page errors", async () => { + const refresh = vi.fn(); + const loadMore = vi.fn(); + mockUseTaskRecommendations.mockReturnValue({ + items: [{ ...recommendations[0], recommendation: { ...recommendations[0].recommendation, createdTaskId: "FN-created" } }], + loading: false, + loadingMore: false, + error: "request failed", + hasMore: true, + totalRowCount: 2, + truncated: false, + refresh, + loadMore, + createTask: vi.fn(), + createStates: new Map(), + }); + + render(); + + await waitFor(() => expect(screen.getByText("Created FN-created")).toBeInTheDocument()); + expect(screen.queryByRole("button", { name: "Create task" })).not.toBeInTheDocument(); + expect(screen.getByText("Could not load more recommendations.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(loadMore).toHaveBeenCalledOnce(); + }); + + it("offers Load more only while another row page is available and surfaces truncation", async () => { + const loadMore = vi.fn(); + mockUseTaskRecommendations.mockReturnValue({ + items: [recommendations[0]], loading: false, loadingMore: false, error: null, + hasMore: true, totalRowCount: 2, truncated: false, refresh: vi.fn(), loadMore, + createTask: vi.fn(), createStates: new Map(), + }); + const { rerender } = render(); + await waitFor(() => expect(screen.getByRole("button", { name: "Load more" })).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(loadMore).toHaveBeenCalledOnce(); + + mockUseTaskRecommendations.mockReturnValue({ + items: [recommendations[0]], loading: false, loadingMore: false, error: null, + hasMore: false, totalRowCount: 1000, truncated: true, refresh: vi.fn(), loadMore: vi.fn(), + createTask: vi.fn(), createStates: new Map(), + }); + rerender(); + expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument(); + expect(screen.getByText("Showing the first 20 pages. Refresh to see the latest recommendations.")).toBeInTheDocument(); + }); + + it("keeps the Load more affordance reachable in the mobile and tablet layouts", () => { + const css = loadAllAppCss(); + expect(css).toMatch(/@media[^{]*\(max-width:\s*768px\)[^{]*\{[\s\S]*?\.insights-recommendations[^}]*\}/); + expect(css).toMatch(/@media[^{]*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)[^{]*\{[\s\S]*?\.insights-recommendations[^}]*\}/); + }); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useTaskRecommendations.test.ts b/packages/dashboard/app/hooks/__tests__/useTaskRecommendations.test.ts new file mode 100644 index 0000000000..91dafd9549 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useTaskRecommendations.test.ts @@ -0,0 +1,130 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useTaskRecommendations } from "../useTaskRecommendations"; + +vi.mock("../../api", () => ({ + fetchTaskRecommendations: vi.fn(), + createTaskFromRecommendation: vi.fn(), +})); + +import { createTaskFromRecommendation, fetchTaskRecommendations } from "../../api"; + +const mockFetchTaskRecommendations = vi.mocked(fetchTaskRecommendations); +const mockCreateTaskFromRecommendation = vi.mocked(createTaskFromRecommendation); + +const item = (taskId: string, recommendationId: string) => ({ + taskId, + taskTitle: `Task ${taskId}`, + recommendation: { + id: recommendationId, + title: `Recommendation ${recommendationId}`, + description: "Follow up", + category: "improvement", + }, +}); + +const page = (items: ReturnType[], options: { rowOffset?: number; returnedRowCount?: number; totalRowCount?: number; hasMore?: boolean } = {}) => ({ + items, + rowOffset: options.rowOffset ?? 0, + rowLimit: 50, + returnedRowCount: options.returnedRowCount ?? 1, + totalRowCount: options.totalRowCount ?? 1, + hasMore: options.hasMore ?? false, +}); + +describe("useTaskRecommendations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("pages by returned source rows and appends only unseen composite recommendation keys", async () => { + mockFetchTaskRecommendations + .mockResolvedValueOnce(page([item("FN-1", "same")], { returnedRowCount: 1, totalRowCount: 3, hasMore: true }) as never) + .mockResolvedValueOnce(page([item("FN-1", "same"), item("FN-2", "same")], { rowOffset: 1, returnedRowCount: 2, totalRowCount: 3 }) as never); + + const { result } = renderHook(() => useTaskRecommendations("project-a")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(mockFetchTaskRecommendations).toHaveBeenLastCalledWith("project-a", { limit: 50, offset: 0 }); + await act(async () => { await result.current.loadMore(); }); + + expect(mockFetchTaskRecommendations).toHaveBeenLastCalledWith("project-a", { limit: 50, offset: 1 }); + expect(result.current.items.map((entry) => `${entry.taskId}:${entry.recommendation.id}`)).toEqual(["FN-1:same", "FN-2:same"]); + expect(result.current.hasMore).toBe(false); + }); + + it("retains loaded recommendations and permits retry after a later page fails", async () => { + mockFetchTaskRecommendations + .mockResolvedValueOnce(page([item("FN-1", "REC-1")], { returnedRowCount: 1, totalRowCount: 2, hasMore: true }) as never) + .mockRejectedValueOnce(new Error("network unavailable")) + .mockResolvedValueOnce(page([item("FN-2", "REC-2")], { rowOffset: 1, returnedRowCount: 1, totalRowCount: 2 }) as never); + + const { result } = renderHook(() => useTaskRecommendations("project-a")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { await result.current.loadMore(); }); + expect(result.current.items).toHaveLength(1); + expect(result.current.error).toBe("network unavailable"); + expect(result.current.hasMore).toBe(true); + + await act(async () => { await result.current.loadMore(); }); + expect(result.current.items.map((entry) => entry.taskId)).toEqual(["FN-1", "FN-2"]); + expect(result.current.error).toBeNull(); + }); + + it("recovers an initial fetch failure through refresh without losing the retry path", async () => { + mockFetchTaskRecommendations + .mockRejectedValueOnce(new Error("initial unavailable")) + .mockResolvedValueOnce(page([item("FN-1", "REC-1")]) as never); + const { result } = renderHook(() => useTaskRecommendations("project-a")); + await waitFor(() => expect(result.current.error).toBe("initial unavailable")); + await act(async () => { await result.current.refresh(); }); + expect(result.current.error).toBeNull(); + expect(result.current.items).toHaveLength(1); + }); + + it("records a create failure only against its composite row key", async () => { + mockFetchTaskRecommendations.mockResolvedValueOnce(page([item("FN-1", "same"), item("FN-2", "same")], { returnedRowCount: 2, totalRowCount: 2 }) as never); + mockCreateTaskFromRecommendation.mockRejectedValueOnce(new Error("create unavailable")); + const { result } = renderHook(() => useTaskRecommendations("project-a")); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { await result.current.createTask("FN-1", "same"); }); + expect(result.current.createStates.get("FN-1:same")).toEqual({ running: false, error: "create unavailable" }); + expect(result.current.createStates.get("FN-2:same")).toBeUndefined(); + }); + + it("drops stale project responses and restarts paging when the project changes", async () => { + let resolveOldPage: (value: ReturnType) => void = () => undefined; + mockFetchTaskRecommendations + .mockImplementationOnce(() => new Promise((resolve) => { resolveOldPage = resolve; }) as never) + .mockResolvedValueOnce(page([item("FN-new", "REC-new")]) as never); + + const { result, rerender } = renderHook(({ projectId }) => useTaskRecommendations(projectId), { initialProps: { projectId: "project-a" } }); + rerender({ projectId: "project-b" }); + + await waitFor(() => expect(mockFetchTaskRecommendations).toHaveBeenLastCalledWith("project-b", { limit: 50, offset: 0 })); + resolveOldPage(page([item("FN-old", "REC-old")])); + await waitFor(() => expect(result.current.items.map((entry) => entry.taskId)).toEqual(["FN-new"])); + }); + + it("tracks creates by task and recommendation id so equal recommendation ids do not collide", async () => { + mockFetchTaskRecommendations.mockResolvedValueOnce(page([item("FN-1", "same"), item("FN-2", "same")], { returnedRowCount: 2, totalRowCount: 2 }) as never); + mockCreateTaskFromRecommendation + .mockResolvedValueOnce({ task: { id: "FN-created-1" } } as never) + .mockResolvedValueOnce({ task: { id: "FN-created-2" } } as never); + + const { result } = renderHook(() => useTaskRecommendations("project-a")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await Promise.all([ + result.current.createTask("FN-1", "same"), + result.current.createTask("FN-2", "same"), + ]); + }); + + expect(mockCreateTaskFromRecommendation).toHaveBeenCalledWith("FN-1", "same", "project-a"); + expect(mockCreateTaskFromRecommendation).toHaveBeenCalledWith("FN-2", "same", "project-a"); + expect(result.current.items.map((entry) => entry.recommendation.createdTaskId)).toEqual(["FN-created-1", "FN-created-2"]); + }); +}); diff --git a/packages/dashboard/app/hooks/useTaskRecommendations.ts b/packages/dashboard/app/hooks/useTaskRecommendations.ts new file mode 100644 index 0000000000..267def1f15 --- /dev/null +++ b/packages/dashboard/app/hooks/useTaskRecommendations.ts @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { TaskRecommendationListItem } from "@fusion/core"; +import { createTaskFromRecommendation, fetchTaskRecommendations } from "../api"; + +export const MAX_RECOMMENDATION_PAGES = 20; +const PAGE_SIZE = 50; +export interface RecommendationActionState { running: boolean; error: string | null; } + +/** + * FNXC:TaskRecommendations 2026-08-13-04:41: + * Insights owns user-driven row-page accumulation so project-wide advisory triage stays bounded + * while Load more remains reachable. Offset movement uses returned source rows, not flattened item + * count; concurrent completions may shift this advisory list, but linking a recommendation preserves + * its parent row and never invalidates this surface's own offset. + */ +export function useTaskRecommendations(projectId?: string) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [totalRowCount, setTotalRowCount] = useState(0); + const [truncated, setTruncated] = useState(false); + const [createStates, setCreateStates] = useState>(new Map()); + const offsetRef = useRef(0); + const pagesRef = useRef(0); + const fetchingRef = useRef(false); + const creatingRef = useRef(new Set()); + const epochRef = useRef(0); + const projectRef = useRef(projectId); + + const fetchPage = useCallback(async (reset: boolean) => { + if (fetchingRef.current) return; + fetchingRef.current = true; + const epoch = epochRef.current; + const offset = reset ? 0 : offsetRef.current; + if (reset) { setLoading(true); } else { setLoadingMore(true); } + setError(null); + try { + const page = await fetchTaskRecommendations(projectId, { limit: PAGE_SIZE, offset }); + if (epoch !== epochRef.current) return; + const merge = (current: TaskRecommendationListItem[]) => { + const next = reset ? [] : current; + const seen = new Set(next.map((item) => `${item.taskId}:${item.recommendation.id}`)); + return [...next, ...page.items.filter((item) => { + const key = `${item.taskId}:${item.recommendation.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + })]; + }; + setItems(merge); + offsetRef.current = page.rowOffset + page.returnedRowCount; + pagesRef.current = reset ? 1 : pagesRef.current + 1; + setTotalRowCount(page.totalRowCount); + const capped = pagesRef.current >= MAX_RECOMMENDATION_PAGES && page.hasMore; + setTruncated(capped); + setHasMore(capped ? false : page.hasMore); + } catch (cause) { + if (epoch === epochRef.current) setError(cause instanceof Error ? cause.message : "Failed to fetch task recommendations"); + } finally { + if (epoch === epochRef.current) { + setLoading(false); + setLoadingMore(false); + } + fetchingRef.current = false; + } + }, [projectId]); + + const refresh = useCallback(async () => { + offsetRef.current = 0; + pagesRef.current = 0; + setTruncated(false); + await fetchPage(true); + }, [fetchPage]); + + useEffect(() => { + projectRef.current = projectId; + epochRef.current += 1; + fetchingRef.current = false; + creatingRef.current.clear(); + offsetRef.current = 0; + pagesRef.current = 0; + setItems([]); setHasMore(false); setTotalRowCount(0); setTruncated(false); setCreateStates(new Map()); + void fetchPage(true); + }, [fetchPage, projectId]); + + const loadMore = useCallback(async () => { + if (!hasMore || truncated || fetchingRef.current) return; + await fetchPage(false); + }, [fetchPage, hasMore, truncated]); + + const createTask = useCallback(async (taskId: string, recommendationId: string) => { + const key = `${taskId}:${recommendationId}`; + if (creatingRef.current.has(key)) return; + creatingRef.current.add(key); + const epoch = epochRef.current; + setCreateStates((current) => new Map(current).set(key, { running: true, error: null })); + try { + const response = await createTaskFromRecommendation(taskId, recommendationId, projectRef.current); + if (epoch !== epochRef.current) return; + setItems((current) => current.map((item) => item.taskId === taskId && item.recommendation.id === recommendationId + ? { ...item, recommendation: { ...item.recommendation, createdTaskId: response.task.id } } + : item)); + setCreateStates((current) => new Map(current).set(key, { running: false, error: null })); + } catch (cause) { + if (epoch === epochRef.current) setCreateStates((current) => new Map(current).set(key, { running: false, error: cause instanceof Error ? cause.message : "Could not create task" })); + } finally { + // FNXC:TaskRecommendations 2026-08-13-04:41: An old project's completion must not + // clear the same composite key after a project switch has started a new create request. + if (epoch === epochRef.current) creatingRef.current.delete(key); + } + }, []); + + return { items, loading, loadingMore, error, hasMore, totalRowCount, truncated, refresh, loadMore, createTask, createStates }; +} diff --git a/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts b/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts index 27d8115bcc..cf25478ff6 100644 --- a/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/task-recommendation-routes.test.ts @@ -35,6 +35,14 @@ function buildApp(seed: Task[], projectId = "project-a") { listTasks: vi.fn(async (options?: { includeDeleted?: boolean }) => tasks.filter((item) => options?.includeDeleted || !item.deletedAt), ), + listTaskRecommendations: vi.fn(async (options?: { completeColumns?: ReadonlySet; limit?: number; offset?: number }) => { + const rows = tasks.filter((item) => !item.deletedAt && !!item.recommendations?.length && (options?.completeColumns ?? new Set(["done"])).has(item.column)) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.id.localeCompare(a.id)); + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 50; + const page = rows.slice(offset, offset + limit); + return { items: page.flatMap((item) => (item.recommendations ?? []).map((recommendation) => ({ taskId: item.id, taskTitle: item.title, taskColumn: item.column, updatedAt: item.updatedAt, recommendation }))), rowOffset: offset, rowLimit: limit, returnedRowCount: page.length, totalRowCount: rows.length, hasMore: offset + page.length < rows.length }; + }), searchTasks: vi.fn(async () => tasks.filter((item) => !item.deletedAt)), findRecentTasksByContentFingerprint: vi.fn(async () => []), getSettingsFast: vi.fn(async () => ({ autoSummarizeTitles: false })), @@ -211,6 +219,24 @@ describe("recommendation task creation route", () => { beforeEach(() => locks?.clear()); afterEach(() => { locks?.clear(); vi.restoreAllMocks(); }); + it("lists completed recommendations with bounded row pagination before task-id routes", async () => { + const first = parent({ id: "FN-1", updatedAt: "2026-08-13T00:00:00.000Z" }); + const second = parent({ id: "FN-2", updatedAt: "2026-08-13T00:00:00.000Z", recommendations: [{ id: "rec-1", title: "Second", description: "Another safe follow-up.", category: "bug" }] }); + const { app, store } = buildApp([first, second]); + const response = await performRequest(app, "GET", "/api/tasks/recommendations?limit=1&offset=0"); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ rowLimit: 1, rowOffset: 0, returnedRowCount: 1, totalRowCount: 2, hasMore: true }); + expect(store.getTask).not.toHaveBeenCalledWith("recommendations"); + expect(await performRequest(app, "GET", "/api/tasks/recommendations?limit=1.5")).toMatchObject({ status: 400 }); + expect(await performRequest(app, "GET", "/api/tasks/recommendations?limit=-1")).toMatchObject({ status: 400 }); + expect(await performRequest(app, "GET", "/api/tasks/recommendations?offset=-1")).toMatchObject({ status: 400 }); + expect(await performRequest(app, "GET", "/api/tasks/recommendations?offset=NaN")).toMatchObject({ status: 400 }); + expect(await performRequest(app, "GET", "/api/tasks/recommendations?limit=999")).toMatchObject({ status: 200 }); + expect(store.listTaskRecommendations).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 200 })); + const finalPage = await performRequest(app, "GET", "/api/tasks/recommendations?limit=1&offset=1"); + expect(finalPage.body).toMatchObject({ rowOffset: 1, rowLimit: 1, returnedRowCount: 1, totalRowCount: 2, hasMore: false }); + }); + it("creates and links exactly one child on concurrent clicks through guarded intake", async () => { const { app, store, tasks } = buildApp([parent()]); const [first, second] = await Promise.all([ diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 97eaf12199..0180b87b5e 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -1588,6 +1588,39 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + /* + FNXC:TaskRecommendations 2026-08-13-04:41: + This literal route must stay before `/tasks/:id`; its bounded row pagination exposes total and + hasMore so operators can intentionally walk every advisory recommendation rather than receive a + silent cap. + */ + router.get("/tasks/recommendations", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const parsePageNumber = (value: unknown, name: "limit" | "offset"): number | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.trim() === "" || !Number.isInteger(Number(value)) || Number(value) < 0) { + throw badRequest(`${name} must be a non-negative integer`); + } + return Number(value); + }; + const requestedLimit = parsePageNumber(req.query.limit, "limit"); + const offset = parsePageNumber(req.query.offset, "offset"); + if (requestedLimit === 0) throw badRequest("limit must be a positive integer"); + const limit = requestedLimit === undefined ? undefined : Math.min(200, requestedLimit); + let completeColumns: ReadonlySet; + try { + completeColumns = await resolveProjectColumnsForRoles(scopedStore, ["complete"]); + } catch { + completeColumns = new Set(["done"]); + } + res.json(await scopedStore.listTaskRecommendations({ completeColumns, limit, offset })); + } 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); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 76a194c56d..deace1a49a 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -3235,7 +3235,16 @@ "unarchiveLabel": "Unarchive this insight", "unarchiveTitle": "Unarchive this insight", "unarchiving": "Unarchiving \"{{title}}\"...", - "usePlanningDefault": "Use planning default" + "usePlanningDefault": "Use planning default", + "recommendations": { + "title": "Task Recommendations", + "count": "Showing {{shown}} of {{total}} source tasks", + "source": "Source: {{task}}", + "loadMore": "Load more", + "loadingMore": "Loading more…", + "loadMoreFailed": "Could not load more recommendations.", + "truncated": "Showing the first 20 pages. Refresh to see the latest recommendations." + } }, "interview": { "addContextDirection": "Add any extra context or direction...", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 0504b358dc..101b80df5e 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -3225,7 +3225,16 @@ "unarchiveLabel": "Desarchivar esta información", "unarchiveTitle": "Desarchivar esta información", "unarchiving": "Desarchivando \"{{title}}\"...", - "usePlanningDefault": "Usar el predeterminado de planificación" + "usePlanningDefault": "Usar el predeterminado de planificación", + "recommendations": { + "title": "", + "count": "", + "source": "", + "loadMore": "", + "loadingMore": "", + "loadMoreFailed": "", + "truncated": "" + } }, "interview": { "addContextDirection": "Agregue contexto o dirección adicional...", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 7dcbeeb53e..6a95975d73 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -3225,7 +3225,16 @@ "unarchiveLabel": "Désarchiver cet insight", "unarchiveTitle": "Désarchiver cet insight", "unarchiving": "Désarchivage de \"{{title}}\"...", - "usePlanningDefault": "Utiliser la valeur par défaut de planification" + "usePlanningDefault": "Utiliser la valeur par défaut de planification", + "recommendations": { + "title": "", + "count": "", + "source": "", + "loadMore": "", + "loadingMore": "", + "loadMoreFailed": "", + "truncated": "" + } }, "interview": { "addContextDirection": "Ajouter du contexte ou des directives supplémentaires...", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 3f07a2696e..5dde39a25e 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -3225,7 +3225,16 @@ "unarchiveLabel": "이 인사이트 보관 해제", "unarchiveTitle": "이 인사이트 보관 해제", "unarchiving": "\"{{title}}\" 보관 해제 중...", - "usePlanningDefault": "계획 기본값 사용" + "usePlanningDefault": "계획 기본값 사용", + "recommendations": { + "title": "", + "count": "", + "source": "", + "loadMore": "", + "loadingMore": "", + "loadMoreFailed": "", + "truncated": "" + } }, "interview": { "addContextDirection": "추가 컨텍스트나 방향을 입력하세요...", diff --git a/packages/i18n/locales/pt-BR/app.json b/packages/i18n/locales/pt-BR/app.json index b6d0b5fda1..0f05635f0b 100644 --- a/packages/i18n/locales/pt-BR/app.json +++ b/packages/i18n/locales/pt-BR/app.json @@ -3235,7 +3235,16 @@ "unarchiveLabel": "Desarquivar este insight", "unarchiveTitle": "Desarquivar este insight", "unarchiving": "Desarquivando \"{{title}}\"...", - "usePlanningDefault": "Usar padrão de planejamento" + "usePlanningDefault": "Usar padrão de planejamento", + "recommendations": { + "title": "", + "count": "", + "source": "", + "loadMore": "", + "loadingMore": "", + "loadMoreFailed": "", + "truncated": "" + } }, "interview": { "addContextDirection": "Adicione contexto ou direcionamento extra...", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 7bd31d1b91..12809778d2 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -3225,7 +3225,16 @@ "unarchiveLabel": "取消存档此洞察", "unarchiveTitle": "取消存档此洞察", "unarchiving": "正在取消存档\"{{title}}\"...", - "usePlanningDefault": "使用规划默认值" + "usePlanningDefault": "使用规划默认值", + "recommendations": { + "title": "", + "count": "", + "source": "", + "loadMore": "", + "loadingMore": "", + "loadMoreFailed": "", + "truncated": "" + } }, "interview": { "addContextDirection": "添加任何额外的上下文或方向...", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 4628e041fb..256a333f81 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -3225,7 +3225,16 @@ "unarchiveLabel": "取消存檔此洞察", "unarchiveTitle": "取消存檔此洞察", "unarchiving": "正在取消存檔\"{{title}}\"...", - "usePlanningDefault": "使用規劃預設值" + "usePlanningDefault": "使用規劃預設值", + "recommendations": { + "title": "", + "count": "", + "source": "", + "loadMore": "", + "loadingMore": "", + "loadMoreFailed": "", + "truncated": "" + } }, "interview": { "addContextDirection": "添加任何額外的上下文或方向...", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index a261b73bab..b60c85f7b3 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -3222,6 +3222,15 @@ export default interface Resources { "model": "Model", "modelConfigured": "Model: {{model}}", "noInsightsYet": "No insights yet", + "recommendations": { + "count": "Showing {{shown}} of {{total}} source tasks", + "loadMore": "Load more", + "loadMoreFailed": "Could not load more recommendations.", + "loadingMore": "Loading more…", + "source": "Source: {{task}}", + "title": "Task Recommendations", + "truncated": "Showing the first 20 pages. Refresh to see the latest recommendations." + }, "runCompleted": "{{created}} created, {{updated}} updated", "showAllInsights": "Show all insights", "showArchived": "Show archived insights",