Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.worktree-terminal.test.tsx
gsxdsm 49faf0afe2 FN-7832: reorder Task Detail tabs and default terminal picker to task worktree
Reworks the Task Detail terminal experience: the embedded Terminal tab now sits between Comments and Cost, its workspace picker defaults to the task's worktree, and the mobile terminal panel is shorter.

- Move the Terminal tab in Task Detail's tab strip to sit right after Comments and before Cost (previously Cost was earlier and Terminal was near the Session tab)
- TerminalModal now defaults its workspace picker to the useWorkspaces entry whose worktree matches the passed defaultCwd, but only until the operator manually changes the selection; the footer/global terminal still defaults to Project Root since it doesn't pass defaultCwd
- Reduce `.detail-section--worktree-terminal`'s mobile min-height from min(65dvh, 14 * --space-2xl) to min(50dvh, 11 * --space-2xl) so tab context and controls stay reachable above the fold
- Update docs/dashboard-guide.md to describe the new Comments → Terminal → Cost tab order and the worktree-matching picker default
- Add regression tests covering the new tab order and the default terminal workspace selection behavior
- Add a patch changeset describing the user-facing change

Files changed:
 .changeset/FN-7832-task-terminal-picker-and-tab-order.md          |  7 +++
 docs/dashboard-guide.md                                           |  9 +--
 packages/dashboard/app/components/TaskDetailModal.css             |  5 +-
 packages/dashboard/app/components/TaskDetailModal.tsx             | 30 +++++-----
 packages/dashboard/app/components/TerminalModal.tsx                | 19 +++++++
 packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx | 15 ++---
 packages/dashboard/app/components/__tests__/TaskDetailModal.worktree-terminal.test.tsx    | 12 ++++
 packages/dashboard/app/components/__tests__/TerminalModal.test.tsx                        | 64 ++++++++++++++++++++++
 8 files changed, 134 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-7832

Fusion-Task-Lineage: 4ee67a65-8564-49c9-b93c-8c3eab05c073

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-11 20:46:05 -07:00

195 lines
6.4 KiB
TypeScript

import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({ svg: "<svg data-testid='mock-mermaid-svg'></svg>" }),
},
}));
const mockEmbeddedTerminal = vi.fn((props: Record<string, unknown>) => (
<div data-testid="mock-worktree-terminal" data-props={JSON.stringify(props)} />
));
vi.mock("../TerminalModal", () => ({
TerminalModal: (props: Record<string, unknown>) => mockEmbeddedTerminal(props),
}));
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
import * as dashboardApi from "../../api";
setupTaskDetailModalHooks();
function renderDetail(task = makeTask({ id: "FN-7813", worktree: "/repo/.worktrees/FN-7813" }), initialTab: "definition" | "worktree-terminal" = "definition") {
return render(
<TaskDetailModal
initialTab={initialTab}
task={task}
projectId="proj-123"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
}
describe("TaskDetailModal worktree terminal tab", () => {
beforeEach(() => {
mockEmbeddedTerminal.mockClear();
vi.spyOn(dashboardApi, "api").mockResolvedValue({ sessions: [] });
});
it("shows the interactive Terminal tab when a single-task worktree exists", async () => {
renderDetail();
expect(await screen.findByRole("button", { name: "Terminal" })).toBeInTheDocument();
});
it("shows the interactive Terminal tab when no worktree exists", async () => {
renderDetail(makeTask({ id: "FN-7813", worktree: undefined }));
expect(await screen.findByRole("button", { name: "Terminal" })).toBeInTheDocument();
});
it("shows the interactive Terminal tab for workspace tasks without a singular worktree", async () => {
renderDetail(makeTask({
id: "FN-7813",
worktree: undefined,
workspaceWorktrees: {
"packages/app": { worktreePath: "/repo/.worktrees/FN-7813-app", branch: "fusion/FN-7813-app" },
},
}));
expect(await screen.findByRole("button", { name: "Terminal" })).toBeInTheDocument();
});
it("keeps the active Terminal tab visible when the worktree disappears and falls back to the project root", async () => {
const { rerender } = renderDetail(undefined, "worktree-terminal");
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
expect(await screen.findByTestId("mock-worktree-terminal")).toBeInTheDocument();
rerender(
<TaskDetailModal
initialTab="worktree-terminal"
task={makeTask({ id: "FN-7813", worktree: undefined })}
projectId="proj-123"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Terminal" })).toHaveClass("detail-tab-active");
expect(screen.getByTestId("mock-worktree-terminal")).toBeInTheDocument();
});
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({
embedded: true,
isOpen: true,
defaultCwd: undefined,
scopeId: "FN-7813",
projectId: "proj-123",
}));
});
it("passes the task worktree and task-scoped namespace into embedded TerminalModal", async () => {
renderDetail(undefined, "worktree-terminal");
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
await screen.findByTestId("mock-worktree-terminal");
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({
embedded: true,
isOpen: true,
defaultCwd: "/repo/.worktrees/FN-7813",
scopeId: "FN-7813",
projectId: "proj-123",
}));
});
it("passes undefined cwd for no-worktree tasks so TerminalModal creates a project-root shell", async () => {
renderDetail(makeTask({ id: "FN-7826", worktree: undefined }), "worktree-terminal");
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
await screen.findByTestId("mock-worktree-terminal");
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({
embedded: true,
isOpen: true,
defaultCwd: undefined,
scopeId: "FN-7826",
projectId: "proj-123",
}));
});
it("passes undefined cwd for workspace tasks so TerminalModal creates a project-root shell", async () => {
renderDetail(makeTask({
id: "FN-7826",
worktree: undefined,
workspaceWorktrees: {
"packages/app": { worktreePath: "/repo/.worktrees/FN-7826-app", branch: "fusion/FN-7826-app" },
},
}), "worktree-terminal");
fireEvent.click(await screen.findByRole("button", { name: "Terminal" }));
await screen.findByTestId("mock-worktree-terminal");
expect(mockEmbeddedTerminal).toHaveBeenLastCalledWith(expect.objectContaining({
embedded: true,
isOpen: true,
defaultCwd: undefined,
scopeId: "FN-7826",
projectId: "proj-123",
}));
});
it("orders Comments, Terminal, and Cost tabs together", async () => {
const { container } = renderDetail();
await screen.findByRole("button", { name: "Terminal" });
const tabLabels = Array.from(container.querySelectorAll<HTMLButtonElement>(".detail-tabs .detail-tab"))
.map((tab) => tab.textContent?.trim());
expect(tabLabels.indexOf("Comments")).toBeGreaterThanOrEqual(0);
expect(tabLabels.indexOf("Terminal")).toBeGreaterThan(tabLabels.indexOf("Comments"));
expect(tabLabels.indexOf("Cost")).toBe(tabLabels.indexOf("Terminal") + 1);
});
it("renders distinct Session and Terminal tab labels when an agent session exists", async () => {
vi.mocked(dashboardApi.api).mockResolvedValueOnce({
sessions: [{
id: "cli-1",
taskId: "FN-7813",
projectId: "proj-123",
adapterId: "claude-local",
agentState: "busy",
terminationReason: null,
autonomyPosture: null,
}],
});
renderDetail();
expect(await screen.findByRole("button", { name: "Session" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Terminal" })).toBeInTheDocument();
});
});