FN-8256: preserve task detail tabs during column changes

Preserve a task detail view's selected tab and related state through live task column updates.

- Reinitialize tabs only when the caller changes initialTab
- Retain dedicated guards for unavailable PR, Summary, and Session tabs
- Cover modal and embedded task-detail tab persistence

Files changed:
 .../dashboard/app/components/TaskDetailModal.tsx   |  20 +-
 .../TaskDetailModal.tab-persistence.test.tsx       | 244 +++++++++++++++++++++
 2 files changed, 261 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-8256

Fusion-Task-Lineage: 86620e72-65b2-4c2f-b0e9-04ecce4d951c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 18:06:14 -07:00
parent 6e5bb5d3d5
commit dbf52886ca
2 changed files with 261 additions and 3 deletions

View File

@@ -813,14 +813,28 @@ export function TaskDetailContent({
*/
const openUndoTask = findOpenUndoTaskForSource(tasks, workingTask.id);
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
const previousInitialTabRef = useRef<TabId | undefined>(initialTab);
const taskColumnRef = useRef(task.column);
const taskDetailChatFirstRef = useRef(taskDetailChatFirst);
taskColumnRef.current = task.column;
taskDetailChatFirstRef.current = taskDetailChatFirst;
/*
FNXC:TaskDetailTabPersistence 2026-07-17-17:46:
FN-8256 / issue #2282 requires live column updates to preserve the modal's selected
tab, Activity segment, and retry expansion. This sync exists only for caller-driven
`initialTab` changes; dedicated guards below own column-invalidated PR and Summary tabs.
*/
useEffect(() => {
setActiveTab(resolveDefaultTab(initialTab, task.column, taskDetailChatFirst));
if (initialTab === previousInitialTabRef.current) return;
previousInitialTabRef.current = initialTab;
setActiveTab(resolveDefaultTab(initialTab, taskColumnRef.current, taskDetailChatFirstRef.current));
setActivitySegment(resolveDefaultActivitySegment(initialTab));
if (initialTab === "retries") {
setRetriesExpanded(true);
}
}, [initialTab, task.column, taskDetailChatFirst]);
}, [initialTab]);
useEffect(() => {
if (activeTab === "pr" && task.column !== "in-review") {

View File

@@ -0,0 +1,244 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Column, Task, TaskDetail } from "@fusion/core";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal";
setupTaskDetailModalHooks();
const sharedProps = {
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
function renderDetail({
task,
initialTab,
embedded = false,
}: {
task: Task | TaskDetail;
initialTab?: "definition" | "documents" | "stats" | "workflow" | "logs" | "retries" | "pr" | "summary" | "terminal";
embedded?: boolean;
}) {
if (embedded) {
return render(
<TaskDetailContent
{...sharedProps}
embedded
initialTab={initialTab}
task={task}
/>,
);
}
return render(
<TaskDetailModal
{...sharedProps}
initialTab={initialTab}
task={task}
onClose={noop}
/>,
);
}
function rerenderDetail(
rerender: ReturnType<typeof render>["rerender"],
{
task,
initialTab,
embedded = false,
}: {
task: Task | TaskDetail;
initialTab?: "definition" | "documents" | "stats" | "workflow" | "logs" | "retries" | "pr" | "summary" | "terminal";
embedded?: boolean;
},
) {
if (embedded) {
rerender(
<TaskDetailContent
{...sharedProps}
embedded
initialTab={initialTab}
task={task}
/>,
);
return;
}
rerender(
<TaskDetailModal
{...sharedProps}
initialTab={initialTab}
task={task}
onClose={noop}
/>,
);
}
function expectActivitySegment(name: "Live" | "Feed" | "Raw") {
if (!screen.queryByRole("menu", { name: "Activity views" })) {
fireEvent.click(screen.getByRole("button", { name: "Activity" }));
}
expect(screen.getByRole("menuitem", { name })).toHaveAttribute("aria-current", "true");
}
/*
FNXC:TaskDetailTabPersistence 2026-07-17-17:46:
FN-8256 / issue #2282 covers the modal and embedded hosts because both share
TaskDetailContent. Live board/SSE column props must not reset user-selected tab
state; only the dedicated invalid-tab guards may redirect the user to Plan.
*/
describe("TaskDetailModal tab persistence", () => {
it.each([
{
label: "the modal host during todo to in-progress",
initialTab: "definition" as const,
from: "todo" as Column,
to: "in-progress" as Column,
activeTab: "Plan",
embedded: false,
slim: false,
},
{
label: "the embedded optimistic host during in-progress to in-review",
initialTab: "documents" as const,
from: "in-progress" as Column,
to: "in-review" as Column,
activeTab: "Artifacts",
embedded: true,
slim: true,
},
{
label: "the modal host during in-review to done",
initialTab: "stats" as const,
from: "in-review" as Column,
to: "done" as Column,
activeTab: "Stats",
embedded: false,
slim: false,
},
{
label: "the embedded host during a board-drag-equivalent live prop update",
initialTab: "workflow" as const,
from: "in-progress" as Column,
to: "todo" as Column,
activeTab: "Workflow",
embedded: true,
slim: false,
},
])("preserves a non-default tab in $label", ({ initialTab, from, to, activeTab, embedded, slim }) => {
const fullTask = makeTask({ id: `FN-${from}-${to}`, column: from, prompt: "# Full task" });
const task = slim ? (() => {
const { prompt: _prompt, ...optimisticTask } = fullTask;
return optimisticTask as Task;
})() : fullTask;
const view = renderDetail({ task, initialTab, embedded });
expect(screen.getByRole("button", { name: activeTab })).toHaveClass("detail-tab-active");
rerenderDetail(view.rerender, {
task: { ...task, column: to },
initialTab,
embedded,
});
expect(screen.getByRole("button", { name: activeTab })).toHaveClass("detail-tab-active");
});
it("preserves a selected non-default Activity segment across a live column update", () => {
const task = makeTask({ column: "todo", prompt: "# Full task" });
const view = renderDetail({ task, initialTab: "logs" });
expectActivitySegment("Feed");
rerenderDetail(view.rerender, { task: { ...task, column: "in-progress" }, initialTab: "logs" });
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
expectActivitySegment("Feed");
});
it("does not collapse expanded retries on a column-only update", () => {
const task = makeTask({
column: "in-progress",
prompt: "# Full task",
retrySummary: { total: 1, recovery: 1 } as TaskDetail["retrySummary"],
});
const view = renderDetail({ task, initialTab: "retries" });
expect(screen.getByRole("button", { name: "Collapse retries details" })).toHaveAttribute("aria-expanded", "true");
rerenderDetail(view.rerender, { task: { ...task, column: "in-review" }, initialTab: "retries" });
expect(screen.getByRole("button", { name: "Collapse retries details" })).toHaveAttribute("aria-expanded", "true");
});
it("keeps caller-driven initialTab changes as the only tab reinitialization path", () => {
const task = makeTask({ column: "todo", prompt: "# Full task", retrySummary: { total: 1, recovery: 1 } as TaskDetail["retrySummary"] });
const view = renderDetail({ task });
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
rerenderDetail(view.rerender, { task, initialTab: "definition" });
expect(screen.getByRole("button", { name: "Plan" })).toHaveClass("detail-tab-active");
rerenderDetail(view.rerender, { task, initialTab: "logs" });
expectActivitySegment("Feed");
rerenderDetail(view.rerender, { task, initialTab: "retries" });
expect(screen.getByRole("button", { name: "Plan" })).toHaveClass("detail-tab-active");
expect(screen.getByRole("button", { name: "Collapse retries details" })).toHaveAttribute("aria-expanded", "true");
});
it("keeps the PR guard when a task leaves in-review", async () => {
const task = makeTask({ column: "in-review", prompt: "# Full task" });
const view = renderDetail({ task, initialTab: "pr" });
expect(screen.getByRole("button", { name: "Pull Request" })).toHaveClass("detail-tab-active");
rerenderDetail(view.rerender, { task: { ...task, column: "todo" }, initialTab: "pr" });
await waitFor(() => expect(screen.getByRole("button", { name: "Plan" })).toHaveClass("detail-tab-active"));
});
it("keeps the Summary guard when a task leaves done", async () => {
const task = makeTask({ column: "done", prompt: "# Full task" });
const view = renderDetail({ task, initialTab: "summary" });
expect(screen.getByRole("button", { name: "Summary" })).toHaveClass("detail-tab-active");
rerenderDetail(view.rerender, { task: { ...task, column: "in-review" }, initialTab: "summary" });
await waitFor(() => expect(screen.getByRole("button", { name: "Plan" })).toHaveClass("detail-tab-active"));
});
it("keeps the Terminal guard when the CLI session disappears", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url) => {
const body = url.toString().includes("FN-TERMINAL")
? { sessions: [{ id: "session-8256", taskId: "FN-TERMINAL", agentState: "ready", adapterId: "claude" }] }
: { sessions: [] };
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
}) as typeof globalThis.fetch;
try {
const task = makeTask({ id: "FN-TERMINAL", column: "in-progress", prompt: "# Full task" });
const view = renderDetail({ task });
await waitFor(() => expect(screen.getByRole("button", { name: "Session" })).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "Session" }));
expect(screen.getByRole("button", { name: "Session" })).toHaveClass("detail-tab-active");
rerenderDetail(view.rerender, { task: makeTask({ id: "FN-NO-SESSION", column: "in-progress", prompt: "# Full task" }) });
await waitFor(() => expect(screen.getByRole("button", { name: "Plan" })).toHaveClass("detail-tab-active"));
} finally {
globalThis.fetch = originalFetch;
}
});
});