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>
This commit is contained in:
7
.changeset/fn-9012-recommendations-tab-visibility.md
Normal file
7
.changeset/fn-9012-recommendations-tab-visibility.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Show the task Recommendations tab only when a completed task has recommendations.
|
||||||
|
category: fix
|
||||||
|
dev: TaskDetailModal gates hasRecommendations on task-owned recommendations (fullDetail?.id === task.id, else the live prop); tab reconciliation waits for that same proof, not detailLoading.
|
||||||
@@ -1499,7 +1499,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 every completed task. 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. Recommendations are distinct from immediately created/delegated tasks, which remain appropriate for required dependency coordination, explicit task requirements, or operator-directed filing. When no recommendations were produced, the tab shows a clear empty state; 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. 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 **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.
|
||||||
|
|||||||
@@ -1217,18 +1217,35 @@ export function TaskDetailContent({
|
|||||||
}, [activeTab, task.column, isDoneColumn, detailFlagsAreForThisTask]);
|
}, [activeTab, task.column, isDoneColumn, detailFlagsAreForThisTask]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:TaskRecommendations 2026-08-09-01:21:
|
FNXC:TaskRecommendations 2026-08-12-23:01:
|
||||||
Completed tasks always expose Recommendations, even when the executor produced no records, so
|
Empty Recommendations tabs on nearly every completed card are operator noise, so visibility now
|
||||||
operators can distinguish an empty result from unavailable functionality. Reconcile away only
|
requires captured content; TaskRecommendationsTab keeps its empty branch as a defensive fallback
|
||||||
after this task's resolved complete-role result says the card left completion; waiting preserves
|
if an open tab's snapshot empties. A task switch briefly merges the prior full-detail snapshot into
|
||||||
a deliberate tab selection while workflow metadata is still unresolved.
|
the next slim prop before effects clear it, so read recommendations only from a snapshot proven to
|
||||||
|
belong to this task. Reconciliation needs that same positive identity proof rather than
|
||||||
|
detailLoading: rejected fetches, stale switch state, and hidden kept-alive hosts can all report not
|
||||||
|
loading while this task's recommendation answer remains unknown. As with PR and Summary, waiting
|
||||||
|
preserves an operator or deep-link tab selection until the answer is safe to act on.
|
||||||
*/
|
*/
|
||||||
const hasRecommendations = isDoneColumn;
|
const detailSnapshotIsForThisTask = fullDetail?.id === task.id;
|
||||||
|
const taskOwnedRecommendations = detailSnapshotIsForThisTask
|
||||||
|
? workingTask.recommendations
|
||||||
|
: task.recommendations;
|
||||||
|
const hasRecommendations = isDoneColumn && (taskOwnedRecommendations?.length ?? 0) > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (detailFlagsAreForThisTask && activeTab === "recommendations" && !hasRecommendations) {
|
if (!detailFlagsAreForThisTask) return;
|
||||||
|
if (!detailSnapshotIsForThisTask) return;
|
||||||
|
if (activeTab === "recommendations" && !hasRecommendations) {
|
||||||
setActiveTab("definition");
|
setActiveTab("definition");
|
||||||
}
|
}
|
||||||
}, [activeTab, detailFlagsAreForThisTask, hasRecommendations]);
|
}, [
|
||||||
|
activeTab,
|
||||||
|
detailFlagsAreForThisTask,
|
||||||
|
detailSnapshotIsForThisTask,
|
||||||
|
fullDetail?.id,
|
||||||
|
hasRecommendations,
|
||||||
|
task.id,
|
||||||
|
]);
|
||||||
|
|
||||||
// Reset planner-chat focus when the operator opens a different task.
|
// Reset planner-chat focus when the operator opens a different task.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function TaskRecommendationsTab({
|
|||||||
return (
|
return (
|
||||||
<section className="task-recommendations" aria-label={t("taskDetail.recommendations.title", "Recommendations")}>
|
<section className="task-recommendations" aria-label={t("taskDetail.recommendations.title", "Recommendations")}>
|
||||||
{recommendations.length === 0 ? (
|
{recommendations.length === 0 ? (
|
||||||
/* FNXC:TaskRecommendations 2026-08-09-01:21: Completed-task recommendations are a stable destination; an empty result communicates that no out-of-scope work was produced without rendering inert creation controls. */
|
/* 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>
|
<p className="task-recommendations__empty">{t("taskDetail.recommendations.empty", "No recommendations were produced for this task.")}</p>
|
||||||
) : recommendations.map((recommendation) => {
|
) : recommendations.map((recommendation) => {
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { Profiler, type ReactNode } from "react";
|
||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { Column, ModelPricingOverrides, TaskTokenUsage } from "@fusion/core";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import type { Column, ModelPricingOverrides, Task, TaskDetail, TaskTokenUsage } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
makeTask,
|
makeTask,
|
||||||
noop,
|
noop,
|
||||||
@@ -33,7 +34,7 @@ function tokenUsage(overrides: Partial<TaskTokenUsage> = {}): TaskTokenUsage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function doneTask(overrides = {}) {
|
function doneTask(overrides: Partial<TaskDetail> = {}) {
|
||||||
return makeTask({
|
return makeTask({
|
||||||
column: "done",
|
column: "done",
|
||||||
summary: "Completed **summary** with `packages/dashboard/app/components/TaskDetailModal.tsx`.",
|
summary: "Completed **summary** with `packages/dashboard/app/components/TaskDetailModal.tsx`.",
|
||||||
@@ -75,6 +76,52 @@ function doneTask(overrides = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slimDoneTask(overrides: Partial<TaskDetail> = {}): Task {
|
||||||
|
const { prompt: _prompt, ...task } = doneTask(overrides);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCommitRecorder() {
|
||||||
|
const commits: { hasRecommendationsButton: boolean; planActive: boolean }[] = [];
|
||||||
|
const wrap = (children: ReactNode) => (
|
||||||
|
<Profiler id="task-detail" onRender={() => {
|
||||||
|
const buttons = Array.from(document.querySelectorAll<HTMLElement>(".detail-tabs .detail-tab"));
|
||||||
|
commits.push({
|
||||||
|
hasRecommendationsButton: buttons.some((button) => button.textContent?.trim() === "Recommendations"),
|
||||||
|
planActive: buttons.some((button) => button.textContent?.trim() === "Plan" && button.classList.contains("detail-tab-active")),
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Profiler>
|
||||||
|
);
|
||||||
|
return { commits, wrap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailModal(task: Task | TaskDetail, initialTab?: "recommendations") {
|
||||||
|
return (
|
||||||
|
<TaskDetailModal
|
||||||
|
task={task}
|
||||||
|
initialTab={initialTab}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("TaskDetailModal Summary tab", () => {
|
describe("TaskDetailModal Summary tab", () => {
|
||||||
it("lands done tasks on Summary by default while keeping Activity first and accessible", () => {
|
it("lands done tasks on Summary by default while keeping Activity first and accessible", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
@@ -183,57 +230,200 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Recommendations available for empty completed tasks and removes it after completion", async () => {
|
it("hides Recommendations unless a completed task carries recommendations", async () => {
|
||||||
const view = render(
|
const empty = render(detailModal(doneTask({ recommendations: undefined }), "recommendations"));
|
||||||
<TaskDetailModal
|
const emptyTabs = document.querySelector(".detail-tabs");
|
||||||
task={doneTask({ recommendations: undefined })}
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
initialTab="recommendations"
|
expect(screen.queryByText("No recommendations were produced for this task.")).toBeNull();
|
||||||
onClose={noop}
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Plan" })));
|
||||||
onMoveTask={noopMove}
|
expect(Array.from(emptyTabs?.querySelectorAll<HTMLElement>(".detail-tab") ?? []).every((tab) => tab.textContent?.trim())).toBe(true);
|
||||||
onDeleteTask={noopDelete}
|
expect(emptyTabs?.querySelector('[aria-label*="Recommendations"], [title*="Recommendations"]')).toBeNull();
|
||||||
onMergeTask={noopMerge}
|
const emptyButtonCount = emptyTabs?.querySelectorAll("button").length;
|
||||||
onOpenDetail={noopOpenDetail}
|
empty.unmount();
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const tabs = document.querySelector(".detail-tabs");
|
const noRecords = render(detailModal(doneTask({ recommendations: [] }), "recommendations"));
|
||||||
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Plan" })));
|
||||||
|
noRecords.unmount();
|
||||||
|
|
||||||
|
const recommendation = { id: "REC-1", title: "Review cache policy", description: "Check retention.", category: "improvement" as const };
|
||||||
|
const populated = render(detailModal(doneTask({ recommendations: [recommendation] }), "recommendations"));
|
||||||
const recommendations = screen.getByRole("button", { name: "Recommendations" });
|
const recommendations = screen.getByRole("button", { name: "Recommendations" });
|
||||||
expectButtonActive(recommendations);
|
expectButtonActive(recommendations);
|
||||||
expect(tabs?.contains(recommendations)).toBe(true);
|
|
||||||
expect(recommendations.classList.contains("detail-tab")).toBe(true);
|
expect(recommendations.classList.contains("detail-tab")).toBe(true);
|
||||||
expect(screen.getByText("No recommendations were produced for this task.")).toBeInTheDocument();
|
expect(document.querySelector(".detail-tabs")?.contains(recommendations)).toBe(true);
|
||||||
|
expect(document.querySelector(".detail-tabs")?.querySelectorAll("button").length).toBe((emptyButtonCount ?? 0) + 1);
|
||||||
|
expect(screen.getByText("Review cache policy")).toBeInTheDocument();
|
||||||
|
populated.unmount();
|
||||||
|
|
||||||
view.rerender(
|
render(detailModal(doneTask({ recommendations: [{ ...recommendation, id: "REC-2", createdTaskId: "FN-123" }] }), "recommendations"));
|
||||||
<TaskDetailModal
|
expect(screen.getByRole("button", { name: "Recommendations" })).toBeInTheDocument();
|
||||||
task={doneTask({ recommendations: [] })}
|
});
|
||||||
initialTab="recommendations"
|
|
||||||
onClose={noop}
|
|
||||||
onMoveTask={noopMove}
|
|
||||||
onDeleteTask={noopDelete}
|
|
||||||
onMergeTask={noopMerge}
|
|
||||||
onOpenDetail={noopOpenDetail}
|
|
||||||
addToast={noop}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expectButtonActive(screen.getByRole("button", { name: "Recommendations" }));
|
|
||||||
expect(screen.getByText("No recommendations were produced for this task.")).toBeInTheDocument();
|
|
||||||
|
|
||||||
view.rerender(
|
it("requires completion before showing Recommendations", () => {
|
||||||
<TaskDetailModal
|
render(detailModal(makeTask({ column: "todo", recommendations: [{ id: "REC-3", title: "Follow up", description: "", category: "feature" }] }), "recommendations"));
|
||||||
task={makeTask({ column: "todo", recommendations: [] })}
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
initialTab="recommendations"
|
});
|
||||||
onClose={noop}
|
|
||||||
onMoveTask={noopMove}
|
it("keeps a Recommendations deep link selected until this slim task resolves", async () => {
|
||||||
onDeleteTask={noopDelete}
|
const pending = deferred<TaskDetail>();
|
||||||
onMergeTask={noopMerge}
|
const fetchTaskDetail = vi.mocked((await import("../../api")).fetchTaskDetail);
|
||||||
onOpenDetail={noopOpenDetail}
|
fetchTaskDetail.mockReset();
|
||||||
addToast={noop}
|
fetchTaskDetail.mockReturnValue(pending.promise);
|
||||||
/>,
|
const task = slimDoneTask({ id: "FN-pending" });
|
||||||
);
|
render(detailModal(task, "recommendations"));
|
||||||
await waitFor(() => expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull());
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Plan" })).not.toHaveClass("detail-tab-active");
|
||||||
|
await act(async () => {
|
||||||
|
pending.resolve(doneTask({ id: task.id, recommendations: [{ id: "REC-pending", title: "Resolved recommendation", description: "", category: "feature" }] }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Recommendations" })));
|
||||||
|
expect(screen.getByText("Resolved recommendation")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles a Recommendations deep link to Plan when pending detail resolves empty", async () => {
|
||||||
|
const pending = deferred<TaskDetail>();
|
||||||
|
const fetchTaskDetail = vi.mocked((await import("../../api")).fetchTaskDetail);
|
||||||
|
fetchTaskDetail.mockReset();
|
||||||
|
fetchTaskDetail.mockReturnValue(pending.promise);
|
||||||
|
const task = slimDoneTask({ id: "FN-pending-empty" });
|
||||||
|
render(detailModal(task, "recommendations"));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Plan" })).not.toHaveClass("detail-tab-active");
|
||||||
|
await act(async () => {
|
||||||
|
pending.resolve(doneTask({ id: task.id, recommendations: [] }));
|
||||||
|
});
|
||||||
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Plan" })));
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Plan" })));
|
||||||
expect(screen.queryByText("No recommendations were produced for this task.")).toBeNull();
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not redirect a Recommendations deep link after detail fetch rejection", async () => {
|
||||||
|
const fetchTaskDetail = vi.mocked((await import("../../api")).fetchTaskDetail);
|
||||||
|
fetchTaskDetail.mockReset();
|
||||||
|
fetchTaskDetail.mockRejectedValue(new Error("unavailable"));
|
||||||
|
render(detailModal(slimDoneTask({ id: "FN-rejected" }), "recommendations"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchTaskDetail).toHaveBeenCalled());
|
||||||
|
await waitFor(() => expect(screen.getByRole("button", { name: "Plan" })).not.toHaveClass("detail-tab-active"));
|
||||||
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reads a prior task snapshot during a slim task switch", async () => {
|
||||||
|
const pending = deferred<TaskDetail>();
|
||||||
|
const fetchTaskDetail = vi.mocked((await import("../../api")).fetchTaskDetail);
|
||||||
|
fetchTaskDetail.mockReset();
|
||||||
|
fetchTaskDetail.mockReturnValue(pending.promise);
|
||||||
|
const recorder = makeCommitRecorder();
|
||||||
|
const taskA = doneTask({ id: "FN-A", recommendations: [{ id: "REC-A", title: "Recommendation A", description: "", category: "bug" }] });
|
||||||
|
const view = render(recorder.wrap(detailModal(taskA, "recommendations")));
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Recommendations" })));
|
||||||
|
|
||||||
|
const priorCommitCount = recorder.commits.length;
|
||||||
|
const taskB = slimDoneTask({ id: "FN-B", recommendations: undefined });
|
||||||
|
view.rerender(recorder.wrap(detailModal(taskB, "recommendations")));
|
||||||
|
expect(recorder.commits.length).toBeGreaterThan(priorCommitCount);
|
||||||
|
const switchCommits = recorder.commits.slice(priorCommitCount);
|
||||||
|
expect(switchCommits.every((commit) => !commit.hasRecommendationsButton)).toBe(true);
|
||||||
|
expect(switchCommits.every((commit) => !commit.planActive)).toBe(true);
|
||||||
|
await waitFor(() => expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull());
|
||||||
|
expect(screen.getByRole("button", { name: "Plan" })).not.toHaveClass("detail-tab-active");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pending.resolve(doneTask({ id: taskB.id, recommendations: [{ id: "REC-B", title: "Recommendation B", description: "", category: "improvement" }] }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Recommendations" })));
|
||||||
|
expect(screen.getByText("Recommendation B")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Recommendation A")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles a task-switched Recommendations deep link when the new detail resolves empty", async () => {
|
||||||
|
const pending = deferred<TaskDetail>();
|
||||||
|
const fetchTaskDetail = vi.mocked((await import("../../api")).fetchTaskDetail);
|
||||||
|
fetchTaskDetail.mockReset();
|
||||||
|
fetchTaskDetail.mockReturnValue(pending.promise);
|
||||||
|
const taskA = doneTask({ id: "FN-switch-A", recommendations: [{ id: "REC-switch-A", title: "Prior recommendation", description: "", category: "bug" }] });
|
||||||
|
const view = render(detailModal(taskA, "recommendations"));
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Recommendations" })));
|
||||||
|
|
||||||
|
const taskB = slimDoneTask({ id: "FN-switch-B", recommendations: undefined });
|
||||||
|
view.rerender(detailModal(taskB, "recommendations"));
|
||||||
|
await act(async () => {
|
||||||
|
pending.resolve(doneTask({ id: taskB.id, recommendations: [] }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Plan" })));
|
||||||
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fetch or redirect Recommendations while an embedded host is hidden", async () => {
|
||||||
|
const pending = deferred<TaskDetail>();
|
||||||
|
const fetchTaskDetail = vi.mocked((await import("../../api")).fetchTaskDetail);
|
||||||
|
fetchTaskDetail.mockReset();
|
||||||
|
fetchTaskDetail.mockReturnValue(pending.promise);
|
||||||
|
const task = slimDoneTask({ id: "FN-hidden" });
|
||||||
|
const props = {
|
||||||
|
task,
|
||||||
|
embedded: true,
|
||||||
|
initialTab: "recommendations" as const,
|
||||||
|
onMoveTask: noopMove,
|
||||||
|
onDeleteTask: noopDelete,
|
||||||
|
onMergeTask: noopMerge,
|
||||||
|
onOpenDetail: noopOpenDetail,
|
||||||
|
addToast: noop,
|
||||||
|
};
|
||||||
|
const view = render(<TaskDetailContent {...props} active={false} />);
|
||||||
|
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: "Plan" })).not.toHaveClass("detail-tab-active");
|
||||||
|
|
||||||
|
view.rerender(<TaskDetailContent {...props} active />);
|
||||||
|
await waitFor(() => expect(fetchTaskDetail).toHaveBeenCalledTimes(1));
|
||||||
|
await act(async () => {
|
||||||
|
pending.resolve(doneTask({ id: task.id, recommendations: [] }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Plan" })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores Recommendations when a hidden embedded host resolves populated detail", async () => {
|
||||||
|
const pending = deferred<TaskDetail>();
|
||||||
|
const fetchTaskDetail = vi.mocked((await import("../../api")).fetchTaskDetail);
|
||||||
|
fetchTaskDetail.mockReset();
|
||||||
|
fetchTaskDetail.mockReturnValue(pending.promise);
|
||||||
|
const task = slimDoneTask({ id: "FN-hidden-populated" });
|
||||||
|
const props = {
|
||||||
|
task,
|
||||||
|
embedded: true,
|
||||||
|
initialTab: "recommendations" as const,
|
||||||
|
onMoveTask: noopMove,
|
||||||
|
onDeleteTask: noopDelete,
|
||||||
|
onMergeTask: noopMerge,
|
||||||
|
onOpenDetail: noopOpenDetail,
|
||||||
|
addToast: noop,
|
||||||
|
};
|
||||||
|
const view = render(<TaskDetailContent {...props} active={false} />);
|
||||||
|
expect(fetchTaskDetail).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("button", { name: "Plan" })).not.toHaveClass("detail-tab-active");
|
||||||
|
|
||||||
|
view.rerender(<TaskDetailContent {...props} active />);
|
||||||
|
await act(async () => {
|
||||||
|
pending.resolve(doneTask({ id: task.id, recommendations: [{ id: "REC-hidden", title: "Restored recommendation", description: "", category: "feature" }] }));
|
||||||
|
});
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Recommendations" })));
|
||||||
|
expect(screen.getByText("Restored recommendation")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates the shared Recommendations tab strip entry at mobile width", () => {
|
||||||
|
const previousWidth = window.innerWidth;
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
|
||||||
|
const empty = render(detailModal(doneTask({ recommendations: [] })));
|
||||||
|
expect(document.querySelector(".detail-tabs")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
render(detailModal(doneTask({ recommendations: [{ id: "REC-mobile", title: "Mobile recommendation", description: "", category: "feature" }] }), "recommendations"));
|
||||||
|
const recommendationButton = screen.getByRole("button", { name: "Recommendations" });
|
||||||
|
expectButtonActive(recommendationButton);
|
||||||
|
expect(document.querySelector(".detail-tabs")?.contains(recommendationButton)).toBe(true);
|
||||||
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits the token-cost section when token usage is absent", () => {
|
it("omits the token-cost section when token usage is absent", () => {
|
||||||
@@ -696,8 +886,8 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
expect(screen.getByText("Completion summary")).toBeTruthy();
|
expect(screen.getByText("Completion summary")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes empty Recommendations through the shared embedded detail entrypoint", () => {
|
it("gates Recommendations through the shared embedded detail entrypoint", async () => {
|
||||||
render(
|
const empty = render(
|
||||||
<TaskDetailContent
|
<TaskDetailContent
|
||||||
task={doneTask({ recommendations: [] })}
|
task={doneTask({ recommendations: [] })}
|
||||||
embedded
|
embedded
|
||||||
@@ -709,8 +899,23 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Recommendations" })).toBeNull();
|
||||||
|
await waitFor(() => expectButtonActive(screen.getByRole("button", { name: "Plan" })));
|
||||||
|
empty.unmount();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailContent
|
||||||
|
task={doneTask({ recommendations: [{ id: "REC-embedded", title: "Embedded recommendation", description: "", category: "bug" }] })}
|
||||||
|
embedded
|
||||||
|
initialTab="recommendations"
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
expectButtonActive(screen.getByRole("button", { name: "Recommendations" }));
|
expectButtonActive(screen.getByRole("button", { name: "Recommendations" }));
|
||||||
expect(screen.getByText("No recommendations were produced for this task.")).toBeInTheDocument();
|
expect(screen.getByText("Embedded recommendation")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user