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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9022-insights-recommendations.md
Normal file
7
.changeset/fn-9022-insights-recommendations.md
Normal file
@@ -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.
|
||||||
@@ -2023,6 +2023,7 @@ Fusion adds an operator-first API surface to diagnose and expedite refinement ta
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| 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/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. |
|
| 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -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.
|
- 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
|
- Dismiss/archive/unarchive insight records as they age
|
||||||
- Create triage tasks from selected insights directly from the view
|
- 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
|
## 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 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 **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 **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 **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`.
|
||||||
<!-- FNXC:Settings-ThinkingLevel 2026-07-13-00:27: The task-detail Models tab now persists validatorThinkingLevel and planningThinkingLevel separately so Reviewer and Planning lanes can choose reasoning effort without changing the Executor lane's task.thinkingLevel. -->
|
<!-- FNXC:Settings-ThinkingLevel 2026-07-13-00:27: The task-detail Models tab now persists validatorThinkingLevel and planningThinkingLevel separately so Reviewer and Planning lanes can choose reasoning effort without changing the Executor lane's task.thinkingLevel. -->
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
136
packages/core/src/__tests__/task-recommendations-list.test.ts
Normal file
136
packages/core/src/__tests__/task-recommendations-list.test.ts
Normal file
@@ -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<string, unknown> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -221,7 +221,7 @@ export {
|
|||||||
} from "./agents/agent-memory-mode.js";
|
} from "./agents/agent-memory-mode.js";
|
||||||
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem, TaskReviewVerdict, TaskReviewerType } from "./types.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. */
|
/* 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 {
|
export type {
|
||||||
TaskCommitAssociation,
|
TaskCommitAssociation,
|
||||||
TaskCommitAssociationConfidence,
|
TaskCommitAssociationConfidence,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ import { deleteTaskImpl, archiveTaskImpl, type DeleteTaskIfResult } from "./task
|
|||||||
import type { TaskDeleteAuditContext } from "./task-delete-attribution.js";
|
import type { TaskDeleteAuditContext } from "./task-delete-attribution.js";
|
||||||
import { updateSettingsImpl, updateGlobalSettingsImpl } from "./task-store/settings-ops.js";
|
import { updateSettingsImpl, updateGlobalSettingsImpl } from "./task-store/settings-ops.js";
|
||||||
import { createTaskBackendImpl, _createTaskInternalBackendImpl, createTaskImpl, createTaskWithReservedIdImpl, _createTaskInternalImpl, _maybeAutoArchiveSameAgentDuplicateImpl } from "./task-store/task-creation.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 { updateTaskUnlockedImpl } from "./task-store/task-update.js";
|
||||||
import { __setTaskActivityLogLimitsForTesting } from "./task-store/comments.js";
|
import { __setTaskActivityLogLimitsForTesting } from "./task-store/comments.js";
|
||||||
import { declaresAnyLifecycleTrait, resolveReviewColumns, resolveTaskLifecycleColumns, type LifecycleColumns } from "./workflows/workflow-lifecycle-traits.js";
|
import { declaresAnyLifecycleTrait, resolveReviewColumns, resolveTaskLifecycleColumns, type LifecycleColumns } from "./workflows/workflow-lifecycle-traits.js";
|
||||||
@@ -1595,6 +1595,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
async clearWorkflowRunStepInstancesAsync(taskId: string, keepRunId?: string): Promise<void> {
|
async clearWorkflowRunStepInstancesAsync(taskId: string, keepRunId?: string): Promise<void> {
|
||||||
return clearWorkflowRunStepInstancesAsyncImpl(this, taskId, keepRunId);
|
return clearWorkflowRunStepInstancesAsyncImpl(this, taskId, keepRunId);
|
||||||
}
|
}
|
||||||
|
async listTaskRecommendations(options?: { completeColumns?: ReadonlySet<string>; limit?: number; offset?: number }): Promise<import("./types.js").TaskRecommendationListPage> {
|
||||||
|
return listTaskRecommendationsImpl(this, options);
|
||||||
|
}
|
||||||
async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> {
|
async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> {
|
||||||
return listTasksForGithubTrackingReconcileImpl(this, options);
|
return listTasksForGithubTrackingReconcileImpl(this, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import {TaskStore, storeLog} from "../store.js";
|
|||||||
import {readFile} from "node:fs/promises";
|
import {readFile} from "node:fs/promises";
|
||||||
import {join} from "node:path";
|
import {join} from "node:path";
|
||||||
import {existsSync, statSync} from "node:fs";
|
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 * 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 "../builtin-traits.js";
|
||||||
import {allowsAutoMergeProcessing} from "../merge/task-merge.js";
|
import {allowsAutoMergeProcessing} from "../merge/task-merge.js";
|
||||||
import {getInReviewStallReason, DEFAULT_STALE_MERGING_MIN_AGE_MS, type InReviewStallContext} from "../tasks/in-review-stall.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).
|
// answer 404 instead of 500 (see TaskNotFoundError in task-store/errors.ts).
|
||||||
import {TaskNotFoundError} from "../task-store/errors.js";
|
import {TaskNotFoundError} from "../task-store/errors.js";
|
||||||
import { resolveProjectColumnsForRoles } from "../project-lane-vocabulary.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. */
|
/** Merge storage tiers while preserving primary-source authority and order. */
|
||||||
function mergePrimaryById<T extends { id: string }>(primary: T[], secondary: T[]): T[] {
|
function mergePrimaryById<T extends { id: string }>(primary: T[], secondary: T[]): T[] {
|
||||||
@@ -908,3 +909,46 @@ export async function getTaskVerificationRequestAsyncImpl(store: TaskStore, task
|
|||||||
const row = rows[0];
|
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;
|
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<string>; limit?: number; offset?: number },
|
||||||
|
): Promise<TaskRecommendationListPage> {
|
||||||
|
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<number>`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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -608,6 +608,8 @@ import type {
|
|||||||
TaskWedgeNotificationState,
|
TaskWedgeNotificationState,
|
||||||
TaskRecommendation,
|
TaskRecommendation,
|
||||||
TaskRecommendationCategory,
|
TaskRecommendationCategory,
|
||||||
|
TaskRecommendationListItem,
|
||||||
|
TaskRecommendationListPage,
|
||||||
Task,
|
Task,
|
||||||
TaskReleaseGateVerdict,
|
TaskReleaseGateVerdict,
|
||||||
TaskVerificationResultSummary,
|
TaskVerificationResultSummary,
|
||||||
@@ -653,6 +655,8 @@ export type {
|
|||||||
TaskWedgeNotificationState,
|
TaskWedgeNotificationState,
|
||||||
TaskRecommendation,
|
TaskRecommendation,
|
||||||
TaskRecommendationCategory,
|
TaskRecommendationCategory,
|
||||||
|
TaskRecommendationListItem,
|
||||||
|
TaskRecommendationListPage,
|
||||||
Task,
|
Task,
|
||||||
TaskReleaseGateVerdict,
|
TaskReleaseGateVerdict,
|
||||||
TaskVerificationResultSummary,
|
TaskVerificationResultSummary,
|
||||||
|
|||||||
@@ -643,6 +643,25 @@ export interface TaskRecommendation {
|
|||||||
createdTaskId?: string;
|
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 {
|
export interface TaskReleaseGateVerdict {
|
||||||
promoteBlocked: boolean;
|
promoteBlocked: boolean;
|
||||||
unplannedForExecution: boolean;
|
unplannedForExecution: boolean;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
updateTask,
|
updateTask,
|
||||||
createTask,
|
createTask,
|
||||||
createTaskFromRecommendation,
|
createTaskFromRecommendation,
|
||||||
|
fetchTaskRecommendations,
|
||||||
connectPlanningStream,
|
connectPlanningStream,
|
||||||
connectSubtaskStream,
|
connectSubtaskStream,
|
||||||
connectMissionInterviewStream,
|
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", () => {
|
describe("createTask", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type {
|
|||||||
export {
|
export {
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
fetchArchivedTasks,
|
fetchArchivedTasks,
|
||||||
|
fetchTaskRecommendations,
|
||||||
fetchTaskDetail,
|
fetchTaskDetail,
|
||||||
fetchTaskPrompt,
|
fetchTaskPrompt,
|
||||||
fetchTaskRuntimeFallback,
|
fetchTaskRuntimeFallback,
|
||||||
@@ -50,6 +51,7 @@ export {
|
|||||||
import type {
|
import type {
|
||||||
DeleteTaskOptions,
|
DeleteTaskOptions,
|
||||||
ArchiveTaskOptions,
|
ArchiveTaskOptions,
|
||||||
|
TaskRecommendationsResponse,
|
||||||
TaskRuntimeFallbackResponse,
|
TaskRuntimeFallbackResponse,
|
||||||
TaskPromptResponse,
|
TaskPromptResponse,
|
||||||
UpdateTaskReviewRequest,
|
UpdateTaskReviewRequest,
|
||||||
@@ -67,6 +69,7 @@ import type {
|
|||||||
export type {
|
export type {
|
||||||
DeleteTaskOptions,
|
DeleteTaskOptions,
|
||||||
ArchiveTaskOptions,
|
ArchiveTaskOptions,
|
||||||
|
TaskRecommendationsResponse,
|
||||||
TaskRuntimeFallbackResponse,
|
TaskRuntimeFallbackResponse,
|
||||||
TaskPromptResponse,
|
TaskPromptResponse,
|
||||||
UpdateTaskReviewRequest,
|
UpdateTaskReviewRequest,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
CurrentPlanEvidence,
|
CurrentPlanEvidence,
|
||||||
DriftReport,
|
DriftReport,
|
||||||
SpecLock,
|
SpecLock,
|
||||||
|
TaskRecommendationListItem,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { withTokenHeader } from "../../auth";
|
import { withTokenHeader } from "../../auth";
|
||||||
import { api, ApiRequestError, buildApiUrl, proxyApi } from "../client/client.js";
|
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));
|
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<TaskRecommendationsResponse> {
|
||||||
|
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<TaskRecommendationsResponse>(withProjectId(`/tasks/recommendations${suffix}`, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
/** A Definition refresh payload deliberately excludes mutable card state. */
|
/** A Definition refresh payload deliberately excludes mutable card state. */
|
||||||
export interface TaskPromptResponse {
|
export interface TaskPromptResponse {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -671,3 +671,30 @@ Insights tablets at 769px–1024px were inheriting the desktop fixed category si
|
|||||||
overflow-y: auto;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { ViewHeader } from "./ViewHeader";
|
|||||||
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
|
import { isNativeStructureDragEnabled, serializeNativeStructureRef } from "../utils/nativeStructureDrag";
|
||||||
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
||||||
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
||||||
|
import { useTaskRecommendations } from "../hooks/useTaskRecommendations";
|
||||||
import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter";
|
import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter";
|
||||||
import type { InsightCategory } from "@fusion/core";
|
import type { InsightCategory } from "@fusion/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
@@ -85,6 +86,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
archivedCount = 0,
|
archivedCount = 0,
|
||||||
showArchived = false,
|
showArchived = false,
|
||||||
} = useInsights(projectId);
|
} = useInsights(projectId);
|
||||||
|
const taskRecommendations = useTaskRecommendations(projectId);
|
||||||
|
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
const [statusType, setStatusType] = useState<"success" | "error" | "info">("info");
|
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);
|
.filter((section) => section.items.length > 0);
|
||||||
}, [populatedSections, backlogHealthOnly]);
|
}, [populatedSections, backlogHealthOnly]);
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<InsightCategory | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<InsightCategory | "recommendations" | null>(null);
|
||||||
|
|
||||||
// Keep selection valid as data changes; default to first populated section.
|
// Keep selection valid as data changes; default to first populated section.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (selectedCategory === "recommendations" && taskRecommendations.items.length > 0) return;
|
||||||
if (filteredSections.length === 0) {
|
if (filteredSections.length === 0) {
|
||||||
if (selectedCategory !== null) setSelectedCategory(null);
|
if (taskRecommendations.items.length > 0) setSelectedCategory("recommendations");
|
||||||
|
else if (selectedCategory !== null) setSelectedCategory(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stillExists = selectedCategory && filteredSections.some((s) => s.category === selectedCategory && s.items.length > 0);
|
const stillExists = selectedCategory && filteredSections.some((s) => s.category === selectedCategory && s.items.length > 0);
|
||||||
if (!stillExists) {
|
if (!stillExists) setSelectedCategory(filteredSections[0]?.category ?? (taskRecommendations.items.length > 0 ? "recommendations" : null));
|
||||||
setSelectedCategory(filteredSections[0].category);
|
}, [filteredSections, selectedCategory, taskRecommendations.items.length]);
|
||||||
}
|
|
||||||
}, [filteredSections, selectedCategory]);
|
|
||||||
|
|
||||||
const activeSection: InsightSection | undefined = useMemo(
|
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],
|
[filteredSections, selectedCategory],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -376,6 +378,23 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderRecommendations = () => (
|
||||||
|
<section className="insights-section insights-recommendations" data-testid="insights-section-recommendations">
|
||||||
|
<div className="insights-section-header"><div className="insights-section-title"><Lightbulb size={18} className="insights-section-icon" /><h3>{t("insights.recommendations.title", "Task Recommendations")}</h3><span className="insights-section-count">{taskRecommendations.items.length}</span></div></div>
|
||||||
|
<p className="insights-recommendations__count">{t("insights.recommendations.count", "Showing {{shown}} of {{total}} source tasks", { shown: taskRecommendations.items.length, total: taskRecommendations.totalRowCount })}</p>
|
||||||
|
<ul className="insights-list">
|
||||||
|
{taskRecommendations.items.map((item) => {
|
||||||
|
const key = `${item.taskId}:${item.recommendation.id}`;
|
||||||
|
const action = taskRecommendations.createStates.get(key);
|
||||||
|
const createdTaskId = item.recommendation.createdTaskId;
|
||||||
|
return <li className="insight-item insights-recommendations__item" key={key} data-testid={`task-recommendation-${key}`}><div className="insight-item-header"><h4 className="insight-item-title">{item.recommendation.title}</h4><span className="insights-category-count">{item.recommendation.category}</span></div><p>{item.recommendation.description}</p><p className="insights-recommendations__source">{t("insights.recommendations.source", "Source: {{task}}", { task: item.taskTitle ? `${item.taskId} — ${item.taskTitle}` : item.taskId })}</p>{createdTaskId ? <span role="status">{t("taskDetail.recommendations.created", "Created {{taskId}}", { taskId: createdTaskId })}</span> : <div><button className="btn btn-primary" type="button" disabled={action?.running} onClick={() => void taskRecommendations.createTask(item.taskId, item.recommendation.id)}>{action?.running ? t("taskDetail.recommendations.creating", "Creating…") : action?.error ? t("taskDetail.recommendations.retry", "Retry creating task") : t("taskDetail.recommendations.create", "Create task")}</button>{action?.error && <span role="status">{t("taskDetail.recommendations.error", "Could not create task. Try again.")}</span>}</div>}</li>;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{taskRecommendations.truncated ? <p role="status">{t("insights.recommendations.truncated", "Showing the first 20 pages. Refresh to see the latest recommendations.")}</p> : taskRecommendations.hasMore ? <button className="btn" type="button" disabled={taskRecommendations.loadingMore} onClick={() => void taskRecommendations.loadMore()}>{taskRecommendations.loadingMore ? t("insights.recommendations.loadingMore", "Loading more…") : t("insights.recommendations.loadMore", "Load more")}</button> : null}
|
||||||
|
{taskRecommendations.error && !taskRecommendations.loading && <div role="status"><span>{t("insights.recommendations.loadMoreFailed", "Could not load more recommendations.")}</span><button className="btn" type="button" onClick={() => void (taskRecommendations.hasMore ? taskRecommendations.loadMore() : taskRecommendations.refresh())}>{t("actions.retry", "Retry")}</button></div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
const renderActiveInsights = () => {
|
const renderActiveInsights = () => {
|
||||||
if (!activeSection) return null;
|
if (!activeSection) return null;
|
||||||
const IconComponent = CATEGORY_ICONS[activeSection.category] ?? Sparkles;
|
const IconComponent = CATEGORY_ICONS[activeSection.category] ?? Sparkles;
|
||||||
@@ -661,6 +680,15 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="insights-error-callout" role="status" data-testid="task-recommendations-error">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>{t("insights.recommendations.loadMoreFailed", "Could not load more recommendations.")}</span>
|
||||||
|
<button className="btn btn-sm" type="button" onClick={() => void taskRecommendations.refresh()}>{t("actions.retry", "Retry")}</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="insights-loading" data-testid="insights-loading">
|
<div className="insights-loading" data-testid="insights-loading">
|
||||||
<RefreshCw size={24} className="spin" />
|
<RefreshCw size={24} className="spin" />
|
||||||
@@ -674,7 +702,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
{t("actions.retry", "Retry")}
|
{t("actions.retry", "Retry")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : totalCount === 0 ? (
|
) : totalCount === 0 && taskRecommendations.items.length === 0 ? (
|
||||||
<div className="insights-empty" data-testid="insights-empty">
|
<div className="insights-empty" data-testid="insights-empty">
|
||||||
<Sparkles size={48} />
|
<Sparkles size={48} />
|
||||||
<h3>{t("insights.noInsightsYet", "No insights yet")}</h3>
|
<h3>{t("insights.noInsightsYet", "No insights yet")}</h3>
|
||||||
@@ -689,10 +717,11 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
|
|||||||
<aside className="insights-sidebar" aria-label={t("insights.categoriesLabel", "Insight categories")}>
|
<aside className="insights-sidebar" aria-label={t("insights.categoriesLabel", "Insight categories")}>
|
||||||
<ul className="insights-category-list">
|
<ul className="insights-category-list">
|
||||||
{filteredSections.map(renderCategoryItem)}
|
{filteredSections.map(renderCategoryItem)}
|
||||||
|
{taskRecommendations.items.length > 0 && <li><button type="button" className={`insights-category-item${selectedCategory === "recommendations" ? " insights-category-item--active" : ""}`} onClick={() => setSelectedCategory("recommendations")} data-testid="insights-category-recommendations"><Lightbulb size={16} className="insights-category-icon" /><span className="insights-category-label">{t("insights.recommendations.title", "Task Recommendations")}</span><span className="insights-category-count">{taskRecommendations.items.length}{taskRecommendations.hasMore ? `/${taskRecommendations.totalRowCount}` : ""}</span></button></li>}
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
<div className="insights-detail">
|
<div className="insights-detail">
|
||||||
{renderActiveInsights()}
|
{selectedCategory === "recommendations" ? renderRecommendations() : renderActiveInsights()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ vi.mock("../../hooks/useInsights", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useTaskRecommendations", () => ({
|
||||||
|
useTaskRecommendations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock lucide-react icons
|
// Mock lucide-react icons
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
Sparkles: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
Sparkles: ({ size = 24, className = "" }: { size?: number; className?: string }) => (
|
||||||
@@ -79,8 +83,10 @@ vi.mock("lucide-react", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { useInsights } from "../../hooks/useInsights";
|
import { useInsights } from "../../hooks/useInsights";
|
||||||
|
import { useTaskRecommendations } from "../../hooks/useTaskRecommendations";
|
||||||
|
|
||||||
const mockUseInsights = vi.mocked(useInsights);
|
const mockUseInsights = vi.mocked(useInsights);
|
||||||
|
const mockUseTaskRecommendations = vi.mocked(useTaskRecommendations);
|
||||||
|
|
||||||
describe("InsightsView", () => {
|
describe("InsightsView", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -99,6 +105,19 @@ describe("InsightsView", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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({
|
mockUseInsights.mockReturnValue({
|
||||||
sections: mockSections,
|
sections: mockSections,
|
||||||
loading: false,
|
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;[^}]*\}/);
|
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(<InsightsView {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<InsightsView {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<InsightsView {...defaultProps} />);
|
||||||
|
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(<InsightsView {...defaultProps} />);
|
||||||
|
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[^}]*\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<typeof item>[], 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<typeof page>) => 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
116
packages/dashboard/app/hooks/useTaskRecommendations.ts
Normal file
116
packages/dashboard/app/hooks/useTaskRecommendations.ts
Normal file
@@ -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<TaskRecommendationListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [totalRowCount, setTotalRowCount] = useState(0);
|
||||||
|
const [truncated, setTruncated] = useState(false);
|
||||||
|
const [createStates, setCreateStates] = useState<Map<string, RecommendationActionState>>(new Map());
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const pagesRef = useRef(0);
|
||||||
|
const fetchingRef = useRef(false);
|
||||||
|
const creatingRef = useRef(new Set<string>());
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ function buildApp(seed: Task[], projectId = "project-a") {
|
|||||||
listTasks: vi.fn(async (options?: { includeDeleted?: boolean }) =>
|
listTasks: vi.fn(async (options?: { includeDeleted?: boolean }) =>
|
||||||
tasks.filter((item) => options?.includeDeleted || !item.deletedAt),
|
tasks.filter((item) => options?.includeDeleted || !item.deletedAt),
|
||||||
),
|
),
|
||||||
|
listTaskRecommendations: vi.fn(async (options?: { completeColumns?: ReadonlySet<string>; 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)),
|
searchTasks: vi.fn(async () => tasks.filter((item) => !item.deletedAt)),
|
||||||
findRecentTasksByContentFingerprint: vi.fn(async () => []),
|
findRecentTasksByContentFingerprint: vi.fn(async () => []),
|
||||||
getSettingsFast: vi.fn(async () => ({ autoSummarizeTitles: false })),
|
getSettingsFast: vi.fn(async () => ({ autoSummarizeTitles: false })),
|
||||||
@@ -211,6 +219,24 @@ describe("recommendation task creation route", () => {
|
|||||||
beforeEach(() => locks?.clear());
|
beforeEach(() => locks?.clear());
|
||||||
afterEach(() => { locks?.clear(); vi.restoreAllMocks(); });
|
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 () => {
|
it("creates and links exactly one child on concurrent clicks through guarded intake", async () => {
|
||||||
const { app, store, tasks } = buildApp([parent()]);
|
const { app, store, tasks } = buildApp([parent()]);
|
||||||
const [first, second] = await Promise.all([
|
const [first, second] = await Promise.all([
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
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) => {
|
router.post("/tasks/duplicate-check", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
|||||||
@@ -3235,7 +3235,16 @@
|
|||||||
"unarchiveLabel": "Unarchive this insight",
|
"unarchiveLabel": "Unarchive this insight",
|
||||||
"unarchiveTitle": "Unarchive this insight",
|
"unarchiveTitle": "Unarchive this insight",
|
||||||
"unarchiving": "Unarchiving \"{{title}}\"...",
|
"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": {
|
"interview": {
|
||||||
"addContextDirection": "Add any extra context or direction...",
|
"addContextDirection": "Add any extra context or direction...",
|
||||||
|
|||||||
@@ -3225,7 +3225,16 @@
|
|||||||
"unarchiveLabel": "Desarchivar esta información",
|
"unarchiveLabel": "Desarchivar esta información",
|
||||||
"unarchiveTitle": "Desarchivar esta información",
|
"unarchiveTitle": "Desarchivar esta información",
|
||||||
"unarchiving": "Desarchivando \"{{title}}\"...",
|
"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": {
|
"interview": {
|
||||||
"addContextDirection": "Agregue contexto o dirección adicional...",
|
"addContextDirection": "Agregue contexto o dirección adicional...",
|
||||||
|
|||||||
@@ -3225,7 +3225,16 @@
|
|||||||
"unarchiveLabel": "Désarchiver cet insight",
|
"unarchiveLabel": "Désarchiver cet insight",
|
||||||
"unarchiveTitle": "Désarchiver cet insight",
|
"unarchiveTitle": "Désarchiver cet insight",
|
||||||
"unarchiving": "Désarchivage de \"{{title}}\"...",
|
"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": {
|
"interview": {
|
||||||
"addContextDirection": "Ajouter du contexte ou des directives supplémentaires...",
|
"addContextDirection": "Ajouter du contexte ou des directives supplémentaires...",
|
||||||
|
|||||||
@@ -3225,7 +3225,16 @@
|
|||||||
"unarchiveLabel": "이 인사이트 보관 해제",
|
"unarchiveLabel": "이 인사이트 보관 해제",
|
||||||
"unarchiveTitle": "이 인사이트 보관 해제",
|
"unarchiveTitle": "이 인사이트 보관 해제",
|
||||||
"unarchiving": "\"{{title}}\" 보관 해제 중...",
|
"unarchiving": "\"{{title}}\" 보관 해제 중...",
|
||||||
"usePlanningDefault": "계획 기본값 사용"
|
"usePlanningDefault": "계획 기본값 사용",
|
||||||
|
"recommendations": {
|
||||||
|
"title": "",
|
||||||
|
"count": "",
|
||||||
|
"source": "",
|
||||||
|
"loadMore": "",
|
||||||
|
"loadingMore": "",
|
||||||
|
"loadMoreFailed": "",
|
||||||
|
"truncated": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"interview": {
|
"interview": {
|
||||||
"addContextDirection": "추가 컨텍스트나 방향을 입력하세요...",
|
"addContextDirection": "추가 컨텍스트나 방향을 입력하세요...",
|
||||||
|
|||||||
@@ -3235,7 +3235,16 @@
|
|||||||
"unarchiveLabel": "Desarquivar este insight",
|
"unarchiveLabel": "Desarquivar este insight",
|
||||||
"unarchiveTitle": "Desarquivar este insight",
|
"unarchiveTitle": "Desarquivar este insight",
|
||||||
"unarchiving": "Desarquivando \"{{title}}\"...",
|
"unarchiving": "Desarquivando \"{{title}}\"...",
|
||||||
"usePlanningDefault": "Usar padrão de planejamento"
|
"usePlanningDefault": "Usar padrão de planejamento",
|
||||||
|
"recommendations": {
|
||||||
|
"title": "",
|
||||||
|
"count": "",
|
||||||
|
"source": "",
|
||||||
|
"loadMore": "",
|
||||||
|
"loadingMore": "",
|
||||||
|
"loadMoreFailed": "",
|
||||||
|
"truncated": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"interview": {
|
"interview": {
|
||||||
"addContextDirection": "Adicione contexto ou direcionamento extra...",
|
"addContextDirection": "Adicione contexto ou direcionamento extra...",
|
||||||
|
|||||||
@@ -3225,7 +3225,16 @@
|
|||||||
"unarchiveLabel": "取消存档此洞察",
|
"unarchiveLabel": "取消存档此洞察",
|
||||||
"unarchiveTitle": "取消存档此洞察",
|
"unarchiveTitle": "取消存档此洞察",
|
||||||
"unarchiving": "正在取消存档\"{{title}}\"...",
|
"unarchiving": "正在取消存档\"{{title}}\"...",
|
||||||
"usePlanningDefault": "使用规划默认值"
|
"usePlanningDefault": "使用规划默认值",
|
||||||
|
"recommendations": {
|
||||||
|
"title": "",
|
||||||
|
"count": "",
|
||||||
|
"source": "",
|
||||||
|
"loadMore": "",
|
||||||
|
"loadingMore": "",
|
||||||
|
"loadMoreFailed": "",
|
||||||
|
"truncated": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"interview": {
|
"interview": {
|
||||||
"addContextDirection": "添加任何额外的上下文或方向...",
|
"addContextDirection": "添加任何额外的上下文或方向...",
|
||||||
|
|||||||
@@ -3225,7 +3225,16 @@
|
|||||||
"unarchiveLabel": "取消存檔此洞察",
|
"unarchiveLabel": "取消存檔此洞察",
|
||||||
"unarchiveTitle": "取消存檔此洞察",
|
"unarchiveTitle": "取消存檔此洞察",
|
||||||
"unarchiving": "正在取消存檔\"{{title}}\"...",
|
"unarchiving": "正在取消存檔\"{{title}}\"...",
|
||||||
"usePlanningDefault": "使用規劃預設值"
|
"usePlanningDefault": "使用規劃預設值",
|
||||||
|
"recommendations": {
|
||||||
|
"title": "",
|
||||||
|
"count": "",
|
||||||
|
"source": "",
|
||||||
|
"loadMore": "",
|
||||||
|
"loadingMore": "",
|
||||||
|
"loadMoreFailed": "",
|
||||||
|
"truncated": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"interview": {
|
"interview": {
|
||||||
"addContextDirection": "添加任何額外的上下文或方向...",
|
"addContextDirection": "添加任何額外的上下文或方向...",
|
||||||
|
|||||||
9
packages/i18n/src/resources.d.ts
vendored
9
packages/i18n/src/resources.d.ts
vendored
@@ -3222,6 +3222,15 @@ export default interface Resources {
|
|||||||
"model": "Model",
|
"model": "Model",
|
||||||
"modelConfigured": "Model: {{model}}",
|
"modelConfigured": "Model: {{model}}",
|
||||||
"noInsightsYet": "No insights yet",
|
"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",
|
"runCompleted": "{{created}} created, {{updated}} updated",
|
||||||
"showAllInsights": "Show all insights",
|
"showAllInsights": "Show all insights",
|
||||||
"showArchived": "Show archived insights",
|
"showArchived": "Show archived insights",
|
||||||
|
|||||||
Reference in New Issue
Block a user