Files
fusion/packages/dashboard/app/components/TaskRecommendationsTab.tsx
gsxdsm cc1041207c FN-9012: hide empty task Recommendations tabs
Show Recommendations only for completed tasks with task-owned recommendations.

- Gate tab visibility and reconciliation on matching task-detail recommendation data.
- Cover empty, populated, stale-detail, and task-switch recommendation states.
- Document the behavior and add a patch changeset.

Files changed:
 .changeset/fn-9012-recommendations-tab-visibility.md      |   7 +
 docs/dashboard-guide.md                                    |   2 +-
 packages/dashboard/app/components/TaskDetailModal.tsx      |  33 ++-
 packages/dashboard/app/components/TaskRecommendationsTab.tsx |   2 +-
 packages/dashboard/app/components/__tests__/TaskDetailModal.summary-tab.test.tsx | 305 +++++++++++++++++----
 5 files changed, 289 insertions(+), 60 deletions(-)

Fusion-Task-Id: FN-9012

Fusion-Task-Lineage: 2dd3b1c6-ab08-43e3-9c9e-c8d3d9cf4692

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-12 16:24:54 -07:00

131 lines
6.0 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Task, TaskRecommendation } from "@fusion/core";
import { createTaskFromRecommendation } from "../api";
import "./TaskRecommendationsTab.css";
export function TaskRecommendationsTab({
task,
projectId,
onTaskReconciled,
}: {
task: Task;
projectId?: string;
/** The route's parent snapshot is authoritative after a durable link mutation. */
onTaskReconciled?: (task: Task) => void;
}) {
const { t } = useTranslation("app");
const [createdIds, setCreatedIds] = useState<Record<string, string>>({});
const creatingIdsRef = useRef(new Set<string>());
const taskIdRef = useRef(task.id);
const [creatingActions, setCreatingActions] = useState<Record<string, true>>({});
const [errorActions, setErrorActions] = useState<Record<string, true>>({});
const recommendations = task.recommendations ?? [];
useEffect(() => {
/*
FNXC:TaskRecommendations 2026-08-08-06:11:
TaskDetailContent is retained while operators switch cards. Creation state belongs to the
recommendation's parent, so clear it before rendering a new task and fence late responses from
the former task; otherwise a matching recommendation ID can falsely show Created on the new card.
*/
taskIdRef.current = task.id;
creatingIdsRef.current.clear();
setCreatedIds({});
setCreatingActions({});
setErrorActions({});
}, [task.id]);
const createRecommendation = async (recommendation: TaskRecommendation) => {
const actionKey = `${task.id}:${recommendation.id}`;
if (creatingIdsRef.current.has(actionKey) || recommendation.createdTaskId || createdIds[actionKey]) return;
/*
FNXC:TaskRecommendations 2026-08-08-06:34:
Creation is independently idempotent per recommendation. Key pending and retry state by both
parent and recommendation so rapid duplicate clicks emit one request without hiding another
recommendation's concurrent action state.
*/
creatingIdsRef.current.add(actionKey);
setCreatingActions((current) => ({ ...current, [actionKey]: true }));
setErrorActions((current) => {
const { [actionKey]: _cleared, ...remaining } = current;
return remaining;
});
try {
const response = await createTaskFromRecommendation(task.id, recommendation.id, projectId);
/*
FNXC:TaskRecommendations 2026-08-08-05:27:
Creation changes the parent recommendation link, not merely this button. Reconcile the
route's authoritative parent snapshot so every TaskDetailContent host and its board owner
converge instead of leaving a stale second Create action after a refresh or remount.
*/
onTaskReconciled?.(response.parent);
if (taskIdRef.current === task.id) {
setCreatedIds((current) => ({ ...current, [actionKey]: response.task.id }));
}
} catch {
if (taskIdRef.current === task.id) setErrorActions((current) => ({ ...current, [actionKey]: true }));
} finally {
creatingIdsRef.current.delete(actionKey);
if (taskIdRef.current === task.id) {
setCreatingActions((current) => {
const { [actionKey]: _cleared, ...remaining } = current;
return remaining;
});
}
}
};
return (
<section className="task-recommendations" aria-label={t("taskDetail.recommendations.title", "Recommendations")}>
{recommendations.length === 0 ? (
/* FNXC:TaskRecommendations 2026-08-12-23:01: TaskDetailModal content-gates this tab, so this empty branch is unreachable by default and only defends an already-open tab whose resolved snapshot empties. */
<p className="task-recommendations__empty">{t("taskDetail.recommendations.empty", "No recommendations were produced for this task.")}</p>
) : recommendations.map((recommendation) => {
/*
FNXC:TaskRecommendations 2026-08-08-07:10:
Effects reset retained-detail state only after React commits. Namespace transient action state by
parent task so a synchronous card switch cannot briefly render a matching recommendation ID
as Created, disabled, or failed before that cleanup runs.
*/
const actionKey = `${task.id}:${recommendation.id}`;
const createdTaskId = recommendation.createdTaskId ?? createdIds[actionKey];
const creating = creatingActions[actionKey] === true;
const failed = errorActions[actionKey] === true;
return (
<article className="task-recommendations__item card" key={recommendation.id}>
<div className="task-recommendations__content">
<div className="task-recommendations__heading">
<h3>{recommendation.title}</h3>
<span className="task-recommendations__category">{recommendation.category}</span>
</div>
<p>{recommendation.description}</p>
</div>
{createdTaskId ? (
<span className="task-recommendations__created" role="status">
{t("taskDetail.recommendations.created", "Created {{taskId}}", { taskId: createdTaskId })}
</span>
) : (
<div className="task-recommendations__action">
<button
className="btn btn-primary"
type="button"
disabled={creating}
onClick={() => void createRecommendation(recommendation)}
>
{creating
? t("taskDetail.recommendations.creating", "Creating…")
: failed
? t("taskDetail.recommendations.retry", "Retry creating task")
: t("taskDetail.recommendations.create", "Create task")}
</button>
{failed && <span className="task-recommendations__error" role="status">{t("taskDetail.recommendations.error", "Could not create task. Try again.")}</span>}
</div>
)}
</article>
);
})}
</section>
);
}