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:
7
.changeset/FN-7813-worktree-terminal-tab.md
Normal file
7
.changeset/FN-7813-worktree-terminal-tab.md
Normal 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".
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7830,7 +7830,8 @@
|
||||
"routing": "Routing",
|
||||
"stats": "Stats",
|
||||
"summary": "Summary",
|
||||
"terminal": "Terminal",
|
||||
"terminal": "Session",
|
||||
"worktreeTerminal": "Terminal",
|
||||
"workflow": "Workflow"
|
||||
},
|
||||
"terminal": {
|
||||
|
||||
@@ -7763,7 +7763,8 @@
|
||||
"review": "Revisión",
|
||||
"routing": "Enrutamiento",
|
||||
"stats": "Estadísticas",
|
||||
"terminal": "",
|
||||
"terminal": "Sesión",
|
||||
"worktreeTerminal": "Terminal",
|
||||
"workflow": "Flujo de trabajo",
|
||||
"summary": "Summary"
|
||||
},
|
||||
|
||||
@@ -7764,7 +7764,8 @@
|
||||
"review": "Révision",
|
||||
"routing": "Routage",
|
||||
"stats": "Statistiques",
|
||||
"terminal": "",
|
||||
"terminal": "Session",
|
||||
"worktreeTerminal": "Terminal",
|
||||
"workflow": "Workflow",
|
||||
"summary": "Summary"
|
||||
},
|
||||
|
||||
@@ -7763,7 +7763,8 @@
|
||||
"review": "검토",
|
||||
"routing": "라우팅",
|
||||
"stats": "통계",
|
||||
"terminal": "",
|
||||
"terminal": "세션",
|
||||
"worktreeTerminal": "터미널",
|
||||
"workflow": "워크플로",
|
||||
"summary": "Summary"
|
||||
},
|
||||
|
||||
@@ -7763,7 +7763,8 @@
|
||||
"review": "审查",
|
||||
"routing": "路由",
|
||||
"stats": "统计",
|
||||
"terminal": "",
|
||||
"terminal": "会话",
|
||||
"worktreeTerminal": "终端",
|
||||
"workflow": "工作流",
|
||||
"summary": "Summary"
|
||||
},
|
||||
|
||||
@@ -7763,7 +7763,8 @@
|
||||
"review": "審查",
|
||||
"routing": "路由",
|
||||
"stats": "統計",
|
||||
"terminal": "",
|
||||
"terminal": "工作階段",
|
||||
"worktreeTerminal": "終端",
|
||||
"workflow": "工作流程",
|
||||
"summary": "Summary"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user