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>
237 lines
11 KiB
TypeScript
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");
|
|
});
|
|
}
|