Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts
gsxdsm 6317fcddb5 FN-7813: add embedded worktree-rooted multi-tab Terminal to Task Detail
Add an interactive, worktree-rooted, multi-tab Terminal tab to the Task Detail
view, distinct from the pre-existing CLI-agent Session tab.

- TaskDetailModal gains a new embedded Terminal tab (single non-workspace
  task with one recorded worktree) that mounts TerminalModal in a new
  `embedded` render mode, rooted at the task's worktree
- Rename the existing agent-session tab label to "Session" to disambiguate
  it from the new Terminal tab
- useTerminalSessions gains task-scoped session storage and a `defaultCwd`
  option so embedded terminal tabs persist separately from footer/global
  project terminal tabs and start in the task worktree
- TerminalModal/CSS updated to support the embedded layout mode
- Update lazy-loaded-views docs test and AGENTS.md exclusion list to cover
  the new `LazyTerminalModal` task-detail-internal surface
- Document the new Session/Terminal tab split in docs/dashboard-guide.md
- Add i18n strings for the new Terminal tab across all locales
- Add a changeset (minor) for @runfusion/fusion

Files changed:
 .changeset/FN-7813-worktree-terminal-tab.md        |   7 +
 AGENTS.md                                          |   2 +-
 docs/dashboard-guide.md                            |   3 +
 .../app/__tests__/lazy-loaded-views-docs.test.ts   |   4 +-
 .../dashboard/app/components/TaskDetailModal.css   |  17 +++
 .../dashboard/app/components/TaskDetailModal.tsx   |  41 +++++-
 .../dashboard/app/components/TerminalModal.css     |  51 +++++++
 .../dashboard/app/components/TerminalModal.tsx     |  71 +++++++---
 .../__tests__/TaskDetailModal.test-helpers.ts      |   3 +
 .../TaskDetailModal.worktree-terminal.test.tsx     | 139 ++++++++++++++++++
 .../components/__tests__/TerminalModal.test.tsx    |  29 ++++
 .../hooks/__tests__/useTerminalSessions.test.ts    | 157 +++++++++++++++++++++
 .../dashboard/app/hooks/useTerminalSessions.ts     |  63 ++++++---
 packages/i18n/locales/en/app.json                  |   3 +-
 packages/i18n/locales/es/app.json                  |   3 +-
 packages/i18n/locales/fr/app.json                  |   3 +-
 packages/i18n/locales/ko/app.json                  |   3 +-
 packages/i18n/locales/zh-CN/app.json               |   3 +-
 packages/i18n/locales/zh-TW/app.json               |   3 +-
 19 files changed, 550 insertions(+), 55 deletions(-)

Fusion-Task-Id: FN-7813

Fusion-Task-Lineage: 4ef86a15-347a-4862-b01c-5063d8004cb8

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-11 00:01:10 -07:00

237 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import React from "react";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
import { clearAuthToken } from "../../auth";
const taskDetailSseSubscriptions = vi.hoisted(() => [] as Array<{
url: string;
options: { events?: Record<string, (event: MessageEvent) => void> };
}>);
export { taskDetailSseSubscriptions };
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((url: string, options: { events?: Record<string, (event: MessageEvent) => void> }) => {
taskDetailSseSubscriptions.push({ url, options });
return vi.fn();
}),
}));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
updateTask: vi.fn().mockResolvedValue({}),
repairOverlapBlocker: vi.fn().mockResolvedValue({ repaired: true, statusCleared: false, reason: "repaired", message: "Repaired", task: makeTask() }),
summarizeTitle: vi.fn().mockResolvedValue("Generated Title"),
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
rebuildTaskSpec: vi.fn().mockResolvedValue(makeTask({ column: "triage", status: "needs-replan" })),
approvePlan: vi.fn().mockResolvedValue({}),
rejectPlan: vi.fn().mockResolvedValue({}),
duplicateTask: vi.fn().mockResolvedValue({}),
refineTask: vi.fn().mockResolvedValue({}),
addSteeringComment: vi.fn(),
assignTask: vi.fn().mockResolvedValue({}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchAgent: vi.fn().mockResolvedValue(null),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
fetchWorkflow: vi.fn().mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: { version: 1, nodes: [], edges: [] } }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }),
approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
pauseTask: vi.fn().mockResolvedValue({}),
unpauseTask: vi.fn().mockResolvedValue({}),
refreshPrStatus: vi.fn(),
fetchWorkflowResults: vi.fn().mockResolvedValue([]),
fetchTaskReview: vi.fn().mockResolvedValue({ reviewState: { source: "reviewer-agent", items: [], addressing: [] }, automationStatus: null, emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode." }),
refreshTaskReview: vi.fn().mockResolvedValue({ reviewState: undefined, automationStatus: null }),
reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), reviewState: undefined }),
ensureTaskPlannerChatSession: vi.fn().mockResolvedValue({ session: { id: "chat-task-planner", agentId: "task-planner:FN-099", title: "FN-099 planner chat", status: "active", projectId: null, modelProvider: null, modelId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", cliSessionFile: null, cliExecutorAdapterId: null, inFlightGeneration: null } }),
fetchChatMessages: vi.fn().mockResolvedValue({ messages: [] }),
streamChatResponse: vi.fn(),
// FNXC:PlannerOversight 2026-07-04-17:00: FN-7517 default overseer-control
// mocks so every existing TaskDetailModal suite gets deterministic
// no-op behavior for the new quick oversight-level-change resolution
// fetch and the nudge/stop/explain controls, rather than falling through
// to the auto-synthesized `undefined`-resolving fallback.
fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, defaults: {} }),
nudgeOverseer: vi.fn().mockResolvedValue({ applied: false, reason: "oversight-off" }),
stopOverseer: vi.fn().mockResolvedValue({ applied: true, reason: "stopped" }),
explainOverseer: vi.fn().mockResolvedValue({ snapshot: null }),
});
});
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrPanel, CustomModelDropdown
vi.mock("lucide-react", () => ({
Pencil: () => null,
Sparkles: (props: any) => React.createElement("svg", { "data-testid": "sparkles-icon", ...props }),
Globe: () => null,
GitPullRequest: () => null,
ExternalLink: () => null,
RefreshCw: () => null,
Plus: () => null,
MessageSquare: () => null,
Check: () => null,
ChevronUp: () => null,
ChevronDown: () => null,
ChevronRight: (props: any) => React.createElement("svg", { "data-testid": "chevron-right-icon", ...props }),
ArrowLeft: () => null,
Zap: () => null,
X: () => null,
Maximize2: () => null,
Minimize2: () => null,
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }),
Square: (props: any) => React.createElement("svg", { "data-testid": "square-icon", ...props }),
Info: (props: any) => React.createElement("svg", { "data-testid": "info-icon", ...props }),
Bot: () => null,
CircleDot: () => null,
XCircle: () => null,
Workflow: () => null,
GitMerge: () => null,
GitBranch: () => null,
Gitlab: () => null,
AlertTriangle: () => null,
Play: () => null,
Flag: () => null,
ArrowDown: () => null,
ArrowUp: () => null,
TriangleAlert: () => null,
Terminal: () => null,
Shield: () => null,
PauseCircle: () => null,
Split: () => null,
Merge: () => null,
Repeat: () => null,
// FNXC:Test 2026-06-24-23:30: WorkflowNodeEditor (lazy-loaded by TaskDetailModal) uses ToggleRight
// for the optional-group node (FN-6880); the explicit mock list omitted it, breaking every
// TaskDetailModal suite at import. Keep this list in sync with the node-editor icon set.
ToggleRight: () => null,
ClipboardCheck: () => null,
ListChecks: () => null,
Code2: () => null,
Cpu: () => null,
Bell: () => null,
// FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 mobile oversight overflow-menu trigger icon.
MoreVertical: (props: any) => React.createElement("svg", { "data-testid": "more-vertical-icon", ...props }),
// FNXC:Test 2026-07-05-11:20: FN-7579 added "ask-user"/"exit-gate" workflow node types to
// WorkflowNodeTypes.tsx (HelpCircle, DoorOpen), which WorkflowNodeEditor/WorkflowResultsTab
// import transitively behind TaskDetailModal's lazy workflow surfaces. The explicit mock list
// omitted them, breaking every TaskDetailModal suite at import (pre-existing gap, unrelated to
// FN-7582's copy change) — keep this list in sync with the node-editor icon set.
HelpCircle: () => null,
DoorOpen: () => null,
}));
vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false })),
}));
// Mock usePluginUiSlots hook
export const mockUsePluginUiSlots = vi.fn((_projectId?: string) => ({
slots: [] as any[],
getSlotsForId: vi.fn((_id: string) => [] as any[]),
loading: false,
error: null,
}));
vi.mock("../../hooks/usePluginUiSlots", () => ({
usePluginUiSlots: (projectId?: string) => mockUsePluginUiSlots(projectId),
}));
export const mockConfirm = vi.fn();
export const mockConfirmWithChoice = vi.fn();
export const mockConfirmWithCheckbox = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({
confirm: mockConfirm,
confirmWithChoice: mockConfirmWithChoice,
confirmWithCheckbox: mockConfirmWithCheckbox,
}),
}));
export function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-099",
description: "Test task",
column: "in-progress" as Column,
dependencies: [],
prompt: "",
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
} as TaskDetail;
}
export const noop = vi.fn();
export const noopMove = vi.fn(async () => ({}) as Task);
export const noopDelete = vi.fn(async () => ({}) as Task);
export const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
export const noopRetry = vi.fn(async () => ({}) as Task);
export const noopOpenDetail = vi.fn();
export function getCssRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleMatch = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
return ruleMatch?.[1] ?? "";
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function expectBaseRule(css: string, selector: string, declaration: string): void {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{[^}]*${escapeRegExp(declaration)}`);
expect(pattern.test(css)).toBe(true);
}
export function readDashboardStylesSource(): string {
return loadAllAppCss();
}
export function loadDashboardCss(): string {
return loadAllAppCss();
}
export function setupTaskDetailModalHooks(): void {
beforeEach(() => {
mockConfirm.mockReset();
mockConfirmWithChoice.mockReset();
mockConfirmWithCheckbox.mockReset();
mockConfirm.mockResolvedValue(true);
mockConfirmWithChoice.mockResolvedValue("primary");
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
clearAuthToken();
localStorage.removeItem("fn.authToken");
taskDetailSseSubscriptions.length = 0;
});
afterEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
}