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>
This commit is contained in:
gsxdsm
2026-07-10 23:44:44 -07:00
parent bd0e99b31f
commit 6317fcddb5
19 changed files with 550 additions and 55 deletions

View File

@@ -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".

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:TaskDetailTerminal 2026-07-10-00:00: FN-7813 gives single-worktree tasks their own Task Detail Terminal tab while preserving the existing CLI-agent Session tab label, so operators can distinguish an interactive shell from the read-only/live agent session transcript. -->
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:

View File

@@ -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",

View File

@@ -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));
}
}

View File

@@ -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")}
</button>
)}
{showWorktreeTerminalTab && (
<button
className={`detail-tab${activeTab === "worktree-terminal" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("worktree-terminal")}
>
{t("taskDetail.tabs.worktreeTerminal", "Terminal")}
</button>
)}
{/* Plugin tabs */}
@@ -4824,6 +4846,19 @@ export function TaskDetailContent({
</Suspense>
) : null}
</div>
) : activeTab === "worktree-terminal" && showWorktreeTerminalTab && taskWorktreeCwd ? (
<div className="detail-section detail-section--worktree-terminal">
<Suspense fallback={<div className="detail-loading"><LoadingSpinner label={t("taskDetail.terminal.loadingInteractive", "Loading interactive terminal…")} /></div>}>
<LazyTerminalModal
isOpen={true}
onClose={() => setActiveTab("definition")}
embedded
defaultCwd={taskWorktreeCwd}
scopeId={task.id}
projectId={projectId}
/>
</Suspense>
</div>
) : (
<>
{/* 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. */}

View File

@@ -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;
}
}

View File

@@ -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<string | null>(null);
const [exitCode, setExitCode] = useState<number | null>(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<number>(() => 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
</span>
{exitCode !== null && <span className="terminal-exit-code" data-testid="terminal-exit-code">{t("terminal.exitLabel", "Exit: {{code}}", { code: exitCode })}</span>}
<span className="terminal-shortcuts terminal-shortcuts--header">{t("terminal.helpText", "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close")}</span>
{!isMobileTerminal && (
{!embedded && !isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleBelowMode}
@@ -2397,7 +2410,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
{isBelowMode ? <PinOff size={14} /> : <Pin size={14} />}
</button>
)}
{!isMobileTerminal && (
{!embedded && !isMobileTerminal && (
<button
className="terminal-clear-btn terminal-clear-btn--shortcut terminal-clear-btn--icon"
onClick={handleToggleDisplayMode}
@@ -2423,7 +2436,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
role={isBelowMode ? "region" : undefined}
aria-label={isBelowMode ? t("terminal.belowRegion", "Pinned terminal") : undefined}
>
{(isDockedMode || isBelowMode) && (
{!embedded && (isDockedMode || isBelowMode) && (
<div
className={isBelowMode ? "terminal-below-resize-handle" : "terminal-docked-resize-handle"}
data-testid="terminal-docked-resize-handle"
@@ -2433,7 +2446,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
onPointerDown={handleDockedResizePointerDown}
/>
)}
{isFloatingMode && TERMINAL_RESIZE_DIRECTIONS.map((direction) => (
{!embedded && isFloatingMode && TERMINAL_RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
className={`terminal-floating-resize-handle terminal-floating-resize-handle--${direction}`}
@@ -2675,7 +2688,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
desktop `.terminal-tabs` (not the mobile dropdown) ahead of title/close
in normal DOM order, so its plain close button needs no order override.
*/}
{isMobileTerminal ? (
{isMobileTerminal && !embedded ? (
<button
className="terminal-close terminal-close--corner"
onClick={onClose}
@@ -2693,18 +2706,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</div>
{isTabletTerminal ? (
<button
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title={t("terminal.closeTerminal", "Close terminal")}
>
<X size={20} />
</button>
) : (
/* Actions — labels hidden on mobile via .terminal-action-label */
<div className="terminal-actions" data-testid="terminal-actions">
{terminalActionControls}
!embedded ? (
<button
className="terminal-close"
onClick={onClose}
@@ -2713,6 +2715,21 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
>
<X size={20} />
</button>
) : null
) : (
/* Actions — labels hidden on mobile via .terminal-action-label */
<div className="terminal-actions" data-testid="terminal-actions">
{terminalActionControls}
{!embedded && (
<button
className="terminal-close"
onClick={onClose}
data-testid="terminal-close-btn"
title={t("terminal.closeTerminal", "Close terminal")}
>
<X size={20} />
</button>
)}
</div>
)}
</>
@@ -3009,6 +3026,14 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
</div>
);
if (embedded) {
return (
<div className="terminal-embedded-host" data-testid="terminal-embedded-host">
{terminalPanel}
</div>
);
}
if (isBelowMode) {
return (
<div className="terminal-below-host" data-testid="terminal-below-host">

View File

@@ -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,

View File

@@ -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: "<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("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(
<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: "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();
});
});

View File

@@ -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(
<TerminalModal
isOpen={true}
onClose={mockOnClose}
embedded
defaultCwd="/project/.worktrees/FN-7813"
scopeId="FN-7813"
projectId="proj-123"
/>,
);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);

View File

@@ -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([]);

View File

@@ -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<T>(promise: Promise<T>, 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<TerminalTab[]>(() => readTabsFromStorage(projectId));
const [tabs, setTabs] = useState<TerminalTab[]>(() => 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;

View File

@@ -7830,7 +7830,8 @@
"routing": "Routing",
"stats": "Stats",
"summary": "Summary",
"terminal": "Terminal",
"terminal": "Session",
"worktreeTerminal": "Terminal",
"workflow": "Workflow"
},
"terminal": {

View File

@@ -7763,7 +7763,8 @@
"review": "Revisión",
"routing": "Enrutamiento",
"stats": "Estadísticas",
"terminal": "",
"terminal": "Sesión",
"worktreeTerminal": "Terminal",
"workflow": "Flujo de trabajo",
"summary": "Summary"
},

View File

@@ -7764,7 +7764,8 @@
"review": "Révision",
"routing": "Routage",
"stats": "Statistiques",
"terminal": "",
"terminal": "Session",
"worktreeTerminal": "Terminal",
"workflow": "Workflow",
"summary": "Summary"
},

View File

@@ -7763,7 +7763,8 @@
"review": "검토",
"routing": "라우팅",
"stats": "통계",
"terminal": "",
"terminal": "세션",
"worktreeTerminal": "터미널",
"workflow": "워크플로",
"summary": "Summary"
},

View File

@@ -7763,7 +7763,8 @@
"review": "审查",
"routing": "路由",
"stats": "统计",
"terminal": "",
"terminal": "会话",
"worktreeTerminal": "终端",
"workflow": "工作流",
"summary": "Summary"
},

View File

@@ -7763,7 +7763,8 @@
"review": "審查",
"routing": "路由",
"stats": "統計",
"terminal": "",
"terminal": "工作階段",
"worktreeTerminal": "終端",
"workflow": "工作流程",
"summary": "Summary"
},