From 6317fcddb551d05b5edb16ad3da830a02f8f636d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 23:44:44 -0700 Subject: [PATCH] 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) --- .changeset/FN-7813-worktree-terminal-tab.md | 7 + AGENTS.md | 2 +- docs/dashboard-guide.md | 3 + .../__tests__/lazy-loaded-views-docs.test.ts | 4 +- .../app/components/TaskDetailModal.css | 17 ++ .../app/components/TaskDetailModal.tsx | 41 ++++- .../app/components/TerminalModal.css | 51 ++++++ .../app/components/TerminalModal.tsx | 71 +++++--- .../__tests__/TaskDetailModal.test-helpers.ts | 3 + ...TaskDetailModal.worktree-terminal.test.tsx | 139 ++++++++++++++++ .../__tests__/TerminalModal.test.tsx | 29 ++++ .../__tests__/useTerminalSessions.test.ts | 157 ++++++++++++++++++ .../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(-) create mode 100644 .changeset/FN-7813-worktree-terminal-tab.md create mode 100644 packages/dashboard/app/components/__tests__/TaskDetailModal.worktree-terminal.test.tsx diff --git a/.changeset/FN-7813-worktree-terminal-tab.md b/.changeset/FN-7813-worktree-terminal-tab.md new file mode 100644 index 0000000000..ca330a9eb0 --- /dev/null +++ b/.changeset/FN-7813-worktree-terminal-tab.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add an interactive worktree-rooted Terminal tab to the task detail view. +category: feature +dev: TaskDetailModal embeds TerminalModal in a new `embedded` mode; useTerminalSessions gains task-scoped storage + defaultCwd. The pre-existing agent-session tab is relabeled "Session". diff --git a/AGENTS.md b/AGENTS.md index 0726759a11..5815d029b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -290,7 +290,7 @@ Keep this AGENTS inventory in sync with App lazy imports, AppModals lazy modal i - `PiExtensionsManager` - `AgentDetailView` -Note: the embedded main-content views Workflows (`_WorkflowEditorView`), Import Tasks (`_ImportTasksView`), Automations (`_AutomationsView`), and Settings (`_SettingsView`) in App.tsx are `_`-prefixed lazy splits that reuse already-documented chunks. Task session terminals, onboarding-internal modals, duplicate `AgentDetailView` imports, and right-dock overflow re-imports of already-counted views are also intentionally excluded. These exclusions stay out of the curated list and count; `lazy-loaded-views-docs.test.ts` asserts them explicitly, so do not add them as bullets. +Note: the embedded main-content views Workflows (`_WorkflowEditorView`), Import Tasks (`_ImportTasksView`), Automations (`_AutomationsView`), and Settings (`_SettingsView`) in App.tsx are `_`-prefixed lazy splits that reuse already-documented chunks. Task session terminals, the Task Detail embedded terminal, onboarding-internal modals, duplicate `AgentDetailView` imports, and right-dock overflow re-imports of already-counted views are also intentionally excluded. These exclusions stay out of the curated list and count; `lazy-loaded-views-docs.test.ts` asserts them explicitly, so do not add them as bullets. ## FNXC_LOG comments: - Please whenever you're working on a codebase. I want you to add comments describing the date of the change (must be in this format yyyy-MM-dd-hh:mm) and describing the requirements or the change in requirements that made you implement certain functionality. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 03ad744e3b..a4b941b46b 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -623,6 +623,9 @@ Mailbox view shows inbox/outbox communication threads and unread state. Fusion embeds a terminal using xterm.js. Desktop and tablet use the footer status bar as the terminal launcher; mobile keeps the full-screen terminal path. + +Task Detail has two terminal-adjacent tabs when both are applicable: **Session** shows the pre-existing CLI agent session transcript/control surface, while **Terminal** embeds the interactive multi-tab terminal inside the task detail body. The interactive **Terminal** tab appears only for non-workspace tasks with a single recorded worktree; its first shell starts in that task worktree, and its terminal tabs are stored separately from the footer/global project terminal tabs. + On Windows, the embedded terminal starts a supported shell inside Fusion, such as Command Prompt (`cmd.exe`) or Windows PowerShell. Windows Terminal (`wt.exe`) is an external terminal host and is not required or launched for the embedded panel, so Fusion should not show native Windows Terminal help/version popups while starting a terminal. If embedded terminal startup fails, Fusion shows an inline error with **Retry** instead of a blocking native dialog; install or repair Windows Terminal separately with `winget install Microsoft.WindowsTerminal` only if you want to use Windows Terminal outside Fusion. Use the terminal on desktop/tablet: diff --git a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts index 43f09401a8..0124499331 100644 --- a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts +++ b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts @@ -95,8 +95,8 @@ const EXPECTED_EXCLUDED_LAZY = [ }, { file: "../components/TaskDetailModal.tsx", - symbols: ["LazySessionTerminal"], - reason: "task session terminal, not a top-level heavy view or modal", + symbols: ["LazySessionTerminal", "LazyTerminalModal"], + reason: "task-detail-internal terminal surfaces, not top-level heavy views or modals", }, { file: "../components/TaskDetailModal.tsx", diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index cb3638a937..4820adf0ed 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -3677,3 +3677,20 @@ Read-only list/placeholder only — not the deferred rich per-repo-status compon .workspace-worktrees-branch { color: var(--text-muted, inherit); } + +/* +FNXC:TaskDetailTerminal 2026-07-10-00:00: +FN-7813's interactive task Terminal tab hosts an embedded TerminalModal, so the section must provide a real flex height and keep terminal scrolling internal to the shell instead of the surrounding detail modal. +*/ +.detail-section--worktree-terminal { + display: flex; + flex-direction: column; + min-height: min(60dvh, calc(var(--space-2xl) * 16)); + overflow: hidden; +} + +@media (max-width: 768px) { + .detail-section--worktree-terminal { + min-height: min(65dvh, calc(var(--space-2xl) * 14)); + } +} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 2cf3e3c968..332635880a 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -226,7 +226,7 @@ function formatDurationCompact(ageMs: number): string { return `${minutes}m`; } -type TabId = "summary" | "definition" | "chat" | "planner-chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`; +type TabId = "summary" | "definition" | "chat" | "planner-chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | "worktree-terminal" | `plugin-${string}`; type ActivitySegment = "current" | "feed" | "raw-logs" | "interventions"; /* @@ -271,10 +271,13 @@ function resolveDefaultActivitySegment(initialTab: TabId | undefined): ActivityS return initialTab === "logs" ? "feed" : "current"; } -// Lazy-load the terminal so xterm + addons stay out of the main bundle (U11). +// Lazy-load terminal surfaces so xterm + addons stay out of the main bundle (U11). const LazySessionTerminal = lazy(() => import("./SessionTerminal").then((m) => ({ default: m.SessionTerminal })), ); +const LazyTerminalModal = lazy(() => + import("./TerminalModal").then((m) => ({ default: m.TerminalModal })), +); /** CLI session record fields the terminal tab needs (mirrors @fusion/core CliSession). */ export interface CliSessionSummaryRecord { @@ -1153,6 +1156,12 @@ export function TaskDetailContent({ [cliSession, cliOneShot, cliGenericIdle], ); const showCliTab = cliTabVisibility.kind !== "hidden"; + /* + FNXC:TaskDetailTerminal 2026-07-10-00:00: + FN-7813 exposes a dedicated interactive Terminal tab only for single-worktree tasks. Workspace tasks have multiple repo roots and no single safe default shell cwd, so they intentionally hide this tab rather than falling back to the project root. + */ + const taskWorktreeCwd = typeof task.worktree === "string" && task.worktree.trim().length > 0 ? task.worktree : undefined; + const showWorktreeTerminalTab = Boolean(taskWorktreeCwd) && !isWorkspaceTask(workingTask); const cliPosture: SessionTerminalPosture | undefined = useMemo(() => { if (!cliSession) return undefined; const p = cliSession.autonomyPosture ?? {}; @@ -1187,6 +1196,11 @@ export function TaskDetailContent({ if (activeTab === "terminal" && !showCliTab) setActiveTab("definition"); }, [activeTab, showCliTab]); + // If the worktree-rooted terminal tab loses its single-worktree cwd, fall back. + useEffect(() => { + if (activeTab === "worktree-terminal" && !showWorktreeTerminalTab) setActiveTab("definition"); + }, [activeTab, showWorktreeTerminalTab]); + // Track mount state to avoid setting state on unmounted component useEffect(() => { mountedRef.current = true; @@ -4439,7 +4453,15 @@ export function TaskDetailContent({ className={`detail-tab${activeTab === "terminal" ? " detail-tab-active" : ""}`} onClick={() => setActiveTab("terminal")} > - {t("taskDetail.tabs.terminal", "Terminal")} + {t("taskDetail.tabs.terminal", "Session")} + + )} + {showWorktreeTerminalTab && ( + )} {/* Plugin tabs */} @@ -4824,6 +4846,19 @@ export function TaskDetailContent({ ) : null} + ) : activeTab === "worktree-terminal" && showWorktreeTerminalTab && taskWorktreeCwd ? ( +
+
}> + setActiveTab("definition")} + embedded + defaultCwd={taskWorktreeCwd} + scopeId={task.id} + projectId={projectId} + /> + + ) : ( <> {/* FNXC:TaskDetailSummaryTab 2026-06-27-00:00: The former inline Definition-tab completion summary is intentionally removed to avoid duplicating the new done-only Summary tab; Definition keeps merge/retry/source metadata below. */} diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 00b07af170..4eaf603804 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -1824,3 +1824,54 @@ reapply the mobile-only `.terminal-shortcuts--header, .terminal-connection-statu touch-action: pan-x pan-y; } } + +/* +FNXC:Terminal 2026-07-10-00:00: +FN-7813 embeds the shared terminal inside Task Detail. The host must fill the parent tab panel, keep scroll/resize inside the terminal surface, and avoid overlay/floating dimensions so Task Detail owns layout at every breakpoint. +*/ +.terminal-embedded-host { + display: flex; + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; + background: var(--surface); + border: thin solid var(--border); + border-radius: var(--radius-lg); +} + +.modal.terminal-modal.terminal-modal--embedded { + position: relative; + flex: 1 1 auto; + width: 100%; + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; + height: auto; + border: 0; + border-radius: 0; + box-shadow: none; + resize: none; +} + +.terminal-modal--embedded .terminal-header { + flex-wrap: wrap; + gap: var(--space-sm); +} + +@media (max-width: 768px) { + .terminal-embedded-host { + border-radius: var(--radius-md); + } + + .terminal-modal--embedded .terminal-header { + align-items: stretch; + } + + .terminal-modal--embedded .terminal-tabs, + .terminal-modal--embedded .terminal-workspace-picker, + .terminal-modal--embedded .terminal-actions { + min-width: 0; + } +} diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index dd2b73dc9c..b205a78c1b 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -516,6 +516,12 @@ interface TerminalModalProps { initialCommand?: string; initialCommandGeneration?: number; projectId?: string; + /** Render the terminal inline inside a parent-owned layout instead of a portaled modal. */ + embedded?: boolean; + /** Worktree/project directory used by the initial scoped tab. */ + defaultCwd?: string; + /** Optional terminal-session namespace, usually the owning task id. */ + scopeId?: string; } /** @@ -533,7 +539,7 @@ interface TerminalModalProps { * * The terminal spawns a real shell (bash/zsh/powershell based on platform). */ -export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandGeneration = 0, projectId }: TerminalModalProps) { +export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandGeneration = 0, projectId, embedded = false, defaultCwd, scopeId }: TerminalModalProps) { const { t } = useTranslation("app"); const [error, setError] = useState(null); const [exitCode, setExitCode] = useState(null); @@ -568,9 +574,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const [isMobileTerminal, setIsMobileTerminal] = useState(() => isTerminalMobileViewport()); // FNXC:TerminalFooter 2026-07-08-15:00: FN-7684 tablet tier (769-1024px, non-mobile) — keeps the desktop display modes (docked/floating/pinned-below), only the action-control render location changes. const [isTabletTerminal, setIsTabletTerminal] = useState(() => isTerminalTabletViewport()); - const isDockedMode = !isMobileTerminal && displayMode === "docked"; - const isFloatingMode = !isMobileTerminal && displayMode === "floating"; - const isBelowMode = !isMobileTerminal && displayMode === "below"; + /* + FNXC:Terminal 2026-07-10-00:00: + FN-7813 embedded mode is parent-layout owned: render in-flow, skip portal/overlay/display-mode chrome, and keep the shared xterm/session/resize observers so Task Detail gets the same terminal behavior without taking over the viewport. + */ + const isDockedMode = !embedded && !isMobileTerminal && displayMode === "docked"; + const isFloatingMode = !embedded && !isMobileTerminal && displayMode === "floating"; + const isBelowMode = !embedded && !isMobileTerminal && displayMode === "below"; // FNXC:FloatingWindow 2026-06-22-21:30: The FLOATING terminal shares the SINGLE cross-type floating z-index stack (floatingWindowStack) so tapping it raises it above every other floating modal regardless of type. A fresh z is claimed each time the modal opens (see effect below); tapping the panel (pointerdown/focus capture) re-raises it. Docked/mobile modes ignore this z-index (full-width bottom panel / full-screen sheet). const [floatingZ, setFloatingZ] = useState(() => nextFloatingZ()); const bringFloatingToFront = useCallback(() => { @@ -1060,7 +1070,10 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG restartActiveTab, retryBootstrap, replaceActiveTabSession, - } = useTerminalSessions(projectId); + } = useTerminalSessions(projectId, { + storageScope: scopeId ? `task:${scopeId}` : undefined, + defaultCwd, + }); /* FNXC:Terminal 2026-07-06-09:15: @@ -2293,7 +2306,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG const isLoading = !isReady || (!activeTab && !bootstrapError); // FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it. const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`; - const modalClassName = `modal terminal-modal${isMobileTerminal ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}`; + const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`; const modalStyle = { ...(keyboardOverlap > 0 ? { @@ -2385,7 +2398,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG {exitCode !== null && {t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}} {t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")} - {!isMobileTerminal && ( + {!embedded && !isMobileTerminal && ( )} - {!isMobileTerminal && ( + {!embedded && !isMobileTerminal && ( - ) : ( - /* Actions — labels hidden on mobile via .terminal-action-label */ -
- {terminalActionControls} + !embedded ? ( + ) : null + ) : ( + /* Actions — labels hidden on mobile via .terminal-action-label */ +
+ {terminalActionControls} + {!embedded && ( + + )}
)} @@ -3009,6 +3026,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
); + if (embedded) { + return ( +
+ {terminalPanel} +
+ ); + } + if (isBelowMode) { return (
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index b635107f80..6eb6b20d2c 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -113,6 +113,9 @@ vi.mock("lucide-react", () => ({ AlertTriangle: () => null, Play: () => null, Flag: () => null, + ArrowDown: () => null, + ArrowUp: () => null, + TriangleAlert: () => null, Terminal: () => null, Shield: () => null, PauseCircle: () => null, diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.worktree-terminal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.worktree-terminal.test.tsx new file mode 100644 index 0000000000..cad1a98733 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.worktree-terminal.test.tsx @@ -0,0 +1,139 @@ +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: "" }), + }, +})); + +const mockEmbeddedTerminal = vi.fn((props: Record) => ( +
+)); + +vi.mock("../TerminalModal", () => ({ + TerminalModal: (props: Record) => 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( + , + ); +} + +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("hides the interactive Terminal tab when no worktree exists", async () => { + renderDetail(makeTask({ id: "FN-7813", worktree: undefined })); + + await waitFor(() => expect(screen.queryByRole("button", { name: "Terminal" })).toBeNull()); + }); + + it("hides 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" }, + }, + })); + + await waitFor(() => expect(screen.queryByRole("button", { name: "Terminal" })).toBeNull()); + }); + + it("falls back from the active worktree terminal tab when eligibility drops", async () => { + const { rerender } = renderDetail(undefined, "worktree-terminal"); + + fireEvent.click(await screen.findByRole("button", { name: "Terminal" })); + expect(await screen.findByTestId("mock-worktree-terminal")).toBeInTheDocument(); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Plan" })).toHaveClass("detail-tab-active"); + }); + expect(screen.queryByTestId("mock-worktree-terminal")).toBeNull(); + }); + + 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("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(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index e709862610..015534de88 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -441,6 +441,35 @@ describe("TerminalModal", () => { expect(container.firstChild).toBeNull(); }); + it("renders embedded mode in-flow without overlay chrome while keeping shell tabs", async () => { + const { container } = render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("terminal-modal")).toBeTruthy(); + }); + + expect(screen.getByTestId("terminal-embedded-host")).toBeTruthy(); + expect(container.querySelector(".terminal-embedded-host")).toBeTruthy(); + expect(screen.queryByTestId("terminal-modal-overlay")).toBeNull(); + expect(screen.queryByTestId("terminal-close-btn")).toBeNull(); + expect(screen.queryByTestId("terminal-popout-toggle")).toBeNull(); + expect(screen.queryByTestId("terminal-pin-toggle")).toBeNull(); + expect(screen.getByTestId("terminal-tabs")).toBeTruthy(); + expect(mockUseTerminalSessions).toHaveBeenCalledWith("proj-123", { + storageScope: "task:FN-7813", + defaultCwd: "/project/.worktrees/FN-7813", + }); + }); + it("keeps the fast new-terminal button and hides the workspace picker when no task worktrees exist", async () => { render(); diff --git a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts index d034b69c83..1ad0d556f5 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts @@ -29,6 +29,8 @@ Object.defineProperty(window, "localStorage", { const TEST_PROJECT_ID = "proj-123"; const TERMINAL_TABS_KEY = scopedKey("kb-terminal-tabs", TEST_PROJECT_ID); +const TASK_TERMINAL_TABS_KEY = scopedKey("kb-terminal-tabs:task:FN-7813", TEST_PROJECT_ID); +const TASK_WORKTREE = "/project/.worktrees/FN-7813"; describe("useTerminalSessions", () => { beforeEach(() => { @@ -69,6 +71,74 @@ describe("useTerminalSessions", () => { expect(mockCreateTerminalSession).toHaveBeenCalledWith(undefined, undefined, undefined, TEST_PROJECT_ID); }); + it("auto-creates first scoped tab in defaultCwd with a basename title", async () => { + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockResolvedValue([]); + mockCreateTerminalSession.mockResolvedValueOnce({ + sessionId: "session-task", + shell: "/bin/bash", + cwd: TASK_WORKTREE, + }); + + const { result } = renderHook(() => + useTerminalSessions(TEST_PROJECT_ID, { + storageScope: "task:FN-7813", + defaultCwd: TASK_WORKTREE, + }), + ); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + expect(mockCreateTerminalSession).toHaveBeenCalledWith( + TASK_WORKTREE, + undefined, + undefined, + TEST_PROJECT_ID, + ); + expect(result.current.activeTab?.title).toBe("FN-7813"); + expect(result.current.activeTab?.cwd).toBe(TASK_WORKTREE); + expect(localStorageMock.getItem).toHaveBeenCalledWith(TASK_TERMINAL_TABS_KEY); + expect(localStorageMock.getItem).not.toHaveBeenCalledWith(TERMINAL_TABS_KEY); + }); + + it("restores scoped tabs without reading the default terminal tab key", async () => { + const scopedTabs = [ + { + id: "tab-task", + sessionId: "session-task", + title: "FN-7813", + cwd: TASK_WORKTREE, + isActive: true, + createdAt: Date.now(), + }, + ]; + localStorageMock.getItem.mockImplementation((key: string) => + key === TASK_TERMINAL_TABS_KEY ? JSON.stringify(scopedTabs) : null, + ); + mockListTerminalSessions.mockResolvedValue([ + { id: "session-task", shell: "/bin/bash", cwd: TASK_WORKTREE, createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const { result } = renderHook(() => + useTerminalSessions(TEST_PROJECT_ID, { + storageScope: "task:FN-7813", + defaultCwd: TASK_WORKTREE, + }), + ); + + await waitFor(() => { + expect(result.current.isReady).toBe(true); + }); + + expect(result.current.tabs).toHaveLength(1); + expect(result.current.activeTab?.sessionId).toBe("session-task"); + expect(localStorageMock.getItem).toHaveBeenCalledWith(TASK_TERMINAL_TABS_KEY); + expect(localStorageMock.getItem).not.toHaveBeenCalledWith(TERMINAL_TABS_KEY); + expect(mockCreateTerminalSession).not.toHaveBeenCalled(); + }); + it("restores tabs from localStorage on mount", async () => { const storedTabs = [ { @@ -670,6 +740,49 @@ describe("useTerminalSessions", () => { expect(result.current.activeTab?.sessionId).toBe("session-new"); }); + it("restarts tabs without explicit cwd in the hook defaultCwd", async () => { + const storedTabs = [ + { + id: "tab-task", + sessionId: "session-task-old", + title: "Task shell", + isActive: true, + createdAt: Date.now(), + }, + ]; + localStorageMock.getItem.mockImplementation((key: string) => + key === TASK_TERMINAL_TABS_KEY ? JSON.stringify(storedTabs) : null, + ); + mockListTerminalSessions.mockResolvedValue([ + { id: "session-task-old", shell: "/bin/bash", cwd: TASK_WORKTREE, createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + mockCreateTerminalSession.mockResolvedValueOnce({ sessionId: "session-task-new", shell: "/bin/bash", cwd: TASK_WORKTREE }); + + const { result } = renderHook(() => + useTerminalSessions(TEST_PROJECT_ID, { + storageScope: "task:FN-7813", + defaultCwd: TASK_WORKTREE, + }), + ); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + await act(async () => { + await result.current.restartActiveTab(); + }); + + expect(mockCreateTerminalSession).toHaveBeenLastCalledWith( + TASK_WORKTREE, + undefined, + undefined, + TEST_PROJECT_ID, + ); + expect(result.current.activeTab?.sessionId).toBe("session-task-new"); + expect(result.current.activeTab?.cwd).toBe(TASK_WORKTREE); + }); + it("restarts worktree tabs in their preserved cwd", async () => { localStorageMock.getItem.mockReturnValue(null); mockListTerminalSessions.mockResolvedValue([]); @@ -770,6 +883,50 @@ describe("useTerminalSessions", () => { expect(result.current.activeTab?.cwd).toBe("/project/.worktrees/FN-7253"); }); + it("replaces tabs without explicit cwd in the hook defaultCwd", async () => { + const storedTabs = [ + { + id: "tab-task", + sessionId: "session-task-old", + title: "Task shell", + isActive: true, + createdAt: Date.now(), + }, + ]; + localStorageMock.getItem.mockImplementation((key: string) => + key === TASK_TERMINAL_TABS_KEY ? JSON.stringify(storedTabs) : null, + ); + mockListTerminalSessions.mockResolvedValue([ + { id: "session-task-old", shell: "/bin/bash", cwd: TASK_WORKTREE, createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + mockCreateTerminalSession.mockResolvedValueOnce({ sessionId: "session-task-replacement", shell: "/bin/bash", cwd: TASK_WORKTREE }); + + const { result } = renderHook(() => + useTerminalSessions(TEST_PROJECT_ID, { + storageScope: "task:FN-7813", + defaultCwd: TASK_WORKTREE, + }), + ); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + + await act(async () => { + await result.current.replaceActiveTabSession(); + }); + + expect(mockCreateTerminalSession).toHaveBeenLastCalledWith( + TASK_WORKTREE, + undefined, + undefined, + TEST_PROJECT_ID, + ); + expect(mockKillPtyTerminalSession).not.toHaveBeenCalled(); + expect(result.current.activeTab?.sessionId).toBe("session-task-replacement"); + expect(result.current.activeTab?.cwd).toBe(TASK_WORKTREE); + }); + it("sets bootstrapError when replacement session creation fails", async () => { localStorageMock.getItem.mockReturnValue(null); mockListTerminalSessions.mockResolvedValue([]); diff --git a/packages/dashboard/app/hooks/useTerminalSessions.ts b/packages/dashboard/app/hooks/useTerminalSessions.ts index 8ca9d0818b..2762138c97 100644 --- a/packages/dashboard/app/hooks/useTerminalSessions.ts +++ b/packages/dashboard/app/hooks/useTerminalSessions.ts @@ -72,11 +72,16 @@ function generateTabId(): string { return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } -function readTabsFromStorage(projectId?: string): TerminalTab[] { +function terminalTabsStorageKey(storageScope?: string): string { + const trimmed = storageScope?.trim(); + return trimmed ? `${STORAGE_KEY}:${trimmed}` : STORAGE_KEY; +} + +function readTabsFromStorage(projectId?: string, storageScope?: string): TerminalTab[] { if (typeof window === "undefined") return []; try { - const stored = getScopedItem(STORAGE_KEY, projectId); + const stored = getScopedItem(terminalTabsStorageKey(storageScope), projectId); if (stored) { return JSON.parse(stored) as TerminalTab[]; } @@ -132,9 +137,24 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise * const { tabs, activeTab, isReady, createTab, closeTab, setActiveTab, updateTabTitle, restartActiveTab } = useTerminalSessions(); * ``` */ -export function useTerminalSessions(projectId?: string): UseTerminalSessionsReturn { +export interface UseTerminalSessionsOptions { + /** Optional namespace for isolating persisted terminal tabs, e.g. `task:FN-123`. */ + storageScope?: string; + /** Optional working directory used when auto-created/replacement tabs have no explicit cwd. */ + defaultCwd?: string; +} + +/** + * FNXC:TerminalWorktrees 2026-07-10-00:00: + * FN-7813 embeds TerminalModal inside Task Detail, so task terminals need an isolated per-task tab namespace and a worktree-rooted default cwd. Omitted options preserve the global footer terminal contract: the original kb-terminal-tabs key and project-root session creation. + */ +export function useTerminalSessions(projectId?: string, options: UseTerminalSessionsOptions = {}): UseTerminalSessionsReturn { + const storageScope = options.storageScope?.trim() || undefined; + const defaultCwd = options.defaultCwd?.trim() || undefined; + const storageKey = terminalTabsStorageKey(storageScope); + // Initialize state synchronously from localStorage (no async here) - const [tabs, setTabs] = useState(() => readTabsFromStorage(projectId)); + const [tabs, setTabs] = useState(() => readTabsFromStorage(projectId, storageScope)); // Track whether validation has completed const [isReady, setIsReady] = useState(false); @@ -150,20 +170,20 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu useEffect(() => { generationRef.current += 1; - setTabs(readTabsFromStorage(projectId)); + setTabs(readTabsFromStorage(projectId, storageScope)); setIsReady(false); setServerAvailable(true); setBootstrapError(null); - }, [projectId]); + }, [projectId, storageScope]); // Persist tabs to localStorage whenever they change useEffect(() => { try { - setScopedItem(STORAGE_KEY, JSON.stringify(tabs), projectId); + setScopedItem(storageKey, JSON.stringify(tabs), projectId); } catch { // Ignore localStorage errors } - }, [projectId, tabs]); + }, [projectId, storageKey, tabs]); // Validate and restore tabs from server on mount useEffect(() => { @@ -187,7 +207,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu the list call below, since its result IS decision-relevant there (which sessionIds still exist server-side). */ - if (readTabsFromStorage(projectId).length === 0) { + if (readTabsFromStorage(projectId, storageScope).length === 0) { if (cancelled || gen !== generationRef.current) return; setServerAvailable(true); setIsReady(true); @@ -259,7 +279,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu return () => { cancelled = true; }; - }, [projectId]); // Re-run when project scope changes + }, [projectId, storageScope]); // Re-run when project or terminal tab storage scope changes // Auto-create first tab if no tabs exist after validation // On Windows, do NOT auto-create because the embedded shell may invoke Windows Terminal @@ -284,7 +304,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu Terminal bootstrap failures must render once inside Fusion and then wait for an explicit Retry, so Windows Terminal help/version output cannot recur through an automatic create-session loop. */ withTimeout( - createTerminalSession(undefined, undefined, undefined, projectId), + createTerminalSession(defaultCwd, undefined, undefined, projectId), BOOTSTRAP_CREATE_TIMEOUT_MS, "createTerminalSession" ) @@ -295,7 +315,8 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu const newTab: TerminalTab = { id: generateTabId(), sessionId: session.sessionId, - title: `Terminal ${tabs.length + 1}`, + title: defaultCwd ? titleFromCwd(defaultCwd) : `Terminal ${tabs.length + 1}`, + ...(defaultCwd ? { cwd: session.cwd } : {}), isActive: true, createdAt: Date.now(), }; @@ -329,7 +350,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu }, 0); return () => clearTimeout(timeout); } - }, [bootstrapError, isReady, serverAvailable, tabs.length, retryGeneration]); // Run when ready or when tabs become empty + }, [bootstrapError, defaultCwd, isReady, projectId, serverAvailable, tabs.length, retryGeneration]); // Run when ready or when tabs become empty /** * Internal create tab function (used for auto-creation and user-initiated creation). @@ -467,17 +488,18 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu const currentActiveTab = tabs.find((t) => t.isActive); if (!currentActiveTab) return; - // Recreate worktree-scoped tabs in their original cwd so restart does not silently fall back to the project root. - const session = await createTerminalSession(currentActiveTab.cwd, undefined, undefined, projectId); + // Recreate worktree-scoped tabs in their original cwd or the hook default so restart does not silently fall back to the project root. + const restartCwd = currentActiveTab.cwd ?? defaultCwd; + const session = await createTerminalSession(restartCwd, undefined, undefined, projectId); setTabs((currentTabs) => currentTabs.map((tab) => tab.id === currentActiveTab.id - ? { ...tab, sessionId: session.sessionId, cwd: currentActiveTab.cwd ? session.cwd : undefined } + ? { ...tab, sessionId: session.sessionId, cwd: restartCwd ? session.cwd : undefined } : tab ) ); - }, [projectId, tabs]); + }, [defaultCwd, projectId, tabs]); /** * Replace the active tab's session with a fresh server session. @@ -499,12 +521,13 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu if (!currentActiveTab) return; try { - const session = await createTerminalSession(currentActiveTab.cwd, undefined, undefined, projectId); + const replacementCwd = currentActiveTab.cwd ?? defaultCwd; + const session = await createTerminalSession(replacementCwd, undefined, undefined, projectId); setTabs((currentTabs) => currentTabs.map((tab) => tab.id === currentActiveTab.id - ? { ...tab, sessionId: session.sessionId, cwd: currentActiveTab.cwd ? session.cwd : undefined } + ? { ...tab, sessionId: session.sessionId, cwd: replacementCwd ? session.cwd : undefined } : tab ) ); @@ -517,7 +540,7 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu err instanceof Error ? err.message : typeof err === "string" ? err : "Failed to create terminal session"; setBootstrapError(message); } - }, [projectId, tabs]); + }, [defaultCwd, projectId, tabs]); // Derive active tab const activeTab = tabs.find((tab) => tab.isActive) ?? null; diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 331f883bf4..8365e53226 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -7830,7 +7830,8 @@ "routing": "Routing", "stats": "Stats", "summary": "Summary", - "terminal": "Terminal", + "terminal": "Session", + "worktreeTerminal": "Terminal", "workflow": "Workflow" }, "terminal": { diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index ebed4cca46..10d8104b99 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -7763,7 +7763,8 @@ "review": "Revisión", "routing": "Enrutamiento", "stats": "Estadísticas", - "terminal": "", + "terminal": "Sesión", + "worktreeTerminal": "Terminal", "workflow": "Flujo de trabajo", "summary": "Summary" }, diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index c58e688829..ec15e7b531 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -7764,7 +7764,8 @@ "review": "Révision", "routing": "Routage", "stats": "Statistiques", - "terminal": "", + "terminal": "Session", + "worktreeTerminal": "Terminal", "workflow": "Workflow", "summary": "Summary" }, diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index d955d448c9..bd893a035e 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -7763,7 +7763,8 @@ "review": "검토", "routing": "라우팅", "stats": "통계", - "terminal": "", + "terminal": "세션", + "worktreeTerminal": "터미널", "workflow": "워크플로", "summary": "Summary" }, diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index c098e4c144..fbdbca9ff2 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -7763,7 +7763,8 @@ "review": "审查", "routing": "路由", "stats": "统计", - "terminal": "", + "terminal": "会话", + "worktreeTerminal": "终端", "workflow": "工作流", "summary": "Summary" }, diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 2de4f9f51b..b902dde44f 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -7763,7 +7763,8 @@ "review": "審查", "routing": "路由", "stats": "統計", - "terminal": "", + "terminal": "工作階段", + "worktreeTerminal": "終端", "workflow": "工作流程", "summary": "Summary" },