feat: share terminal sessions across browsers
Terminal PTYs already lived in a server-side registry whose WebSocket attach supports several viewers, but the tab list is per-browser localStorage and a browser with no stored tabs skipped the session listing entirely (FN-7686's cold-open optimization) and spawned its own PTY. Two people on one Fusion — or one person in a second browser — therefore never saw each other's terminals and quietly accumulated parallel sessions. - A zero-tab client adopts the server's sessions, oldest first, so every client converges on the same set. A client with stored tabs still only validates them; adopting there would resurrect tabs the user closed here. - FN-7686's guarantee weakens from "never waits" to "waits at most ADOPT_LIST_TIMEOUT_MS (1.5s), then behaves exactly as before": auto-create fires on a 0ms timer, so a background list can never win that race. Its regression test and comment now state the bounded contract. - Closing a tab asks which close was meant — detach here (PTY keeps running for other viewers) or end the session for everyone. alwaysAsk is set: this gates an informed choice and both wrong answers are destructive. Desktop and mobile close controls both route through it. - A footer Reopen control lists sessions the server still runs that this browser is not showing and reattaches to them, so detaching is not a one-way door. Also fixes a multi-viewer data bug: the attach called getScrollbackAndClearPending(), discarding queued output and deleting a slice of every already-attached viewer's live stream. It now flushes pending output to existing viewers first, then reads scrollback for the newcomer, so each viewer receives it exactly once. Drive-by: closeTab's dependency array was empty while its body reads projectId, freezing it at the first render's project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/shared-terminal-sessions.md
Normal file
7
.changeset/shared-terminal-sessions.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Terminal sessions are now shared across browsers, with close-here vs end-session and a Reopen control.
|
||||
category: feature
|
||||
dev: PTYs already lived in a server-side registry that accepts multiple attached viewers, but the tab list is per-browser localStorage and a browser with no stored tabs skipped the session listing entirely (FN-7686's cold-open optimization) and spawned its own PTY — so a second browser never saw existing terminals. A zero-tab client now adopts the server's sessions (oldest first, identical ordering everywhere); clients with stored tabs still only validate, so closed tabs are not resurrected. FN-7686's guarantee weakens from "never waits" to "waits at most ADOPT_LIST_TIMEOUT_MS (1.5s), then behaves as before", because auto-create fires on a 0ms timer and a background list could never win that race. `closeTab` takes `{ killSession }` and both the desktop and mobile close controls route through a three-way confirm (`alwaysAsk`, so skip-confirmations cannot silently pick). New `detachedSessions`/`refreshDetachedSessions`/`reopenSession` back a footer control that reattaches to running sessions. Also fixes a multi-viewer data bug: the WebSocket attach called `getScrollbackAndClearPending()`, discarding queued output and deleting a slice of every already-attached viewer's live stream; it now calls the new `flushPendingOutput()` then `getScrollback()`.
|
||||
@@ -851,6 +851,15 @@ On first launch or first open, the terminal keeps reconnecting automatically unt
|
||||
FNXC:TaskDetailTerminal 2026-07-11-00:00: Task Detail's collaboration strip groups Comments → Terminal → Cost, and the embedded terminal picker mirrors the task worktree when that worktree is registered so operators can see the cwd context; the global footer terminal still defaults to Project Root. -->
|
||||
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 is always available in Task Detail and sits immediately after **Comments**, with **Cost** immediately after **Terminal**. Its first shell starts in the task worktree when one is recorded, otherwise it starts in the project base directory (project root), including for multi-repo workspace tasks that have no single task worktree. When that task worktree is registered in the workspace picker, the picker shows the task worktree instead of **Project Root**; the footer/global project terminal keeps its separate Project Root default. Task-detail terminal tabs are stored separately from the footer/global project terminal tabs.
|
||||
|
||||
<!-- FNXC:TerminalSharing 2026-08-19-04:10: Terminal PTYs are server-side and accept several attached viewers, so sessions are shared across browsers rather than private to one. A browser with no stored tabs adopts the running sessions; closing a tab asks whether to detach here or end the session for everyone; the footer's Reopen control reattaches to sessions this browser is not showing. -->
|
||||
### Shared terminal sessions
|
||||
|
||||
Terminal sessions run on the Fusion server, not in your browser, and several browsers can watch and type into the same session. Open Fusion in a second browser (or hand the URL to someone else on the same instance) and its terminal shows the sessions already running instead of starting a private one.
|
||||
|
||||
- **Closing a tab asks what you meant.** **Close in this browser** removes the tab here and leaves the session running for anyone else attached — and for you to reopen later. **End session** kills the shell for everyone. This prompt always appears, even with confirmation dialogs disabled, because guessing either way is destructive: one strands a session, the other destroys someone else's shell.
|
||||
- **Reopen** in the terminal's bottom action footer lists sessions the server still runs that this browser is not showing — ones you closed here, or ones another browser opened — and reattaches to them. It is hidden when every running session is already open here.
|
||||
- A browser that already has its own tabs keeps them; it does not adopt. Otherwise reopening Fusion would resurrect tabs you deliberately closed.
|
||||
|
||||
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.
|
||||
|
||||
<!-- FNXC:TerminalFooter 2026-07-11-20:45: FN-7829 keeps terminal action controls (font size, Clear, Shortcuts, Preferences, and status) in the bottom terminal footer at every width, while pin and pop-out sit immediately left of close in the non-mobile top toolbar; desktop/tablet tabs fall back to the mobile-style selector when the tab strip cannot fit its container. -->
|
||||
|
||||
@@ -1491,7 +1491,8 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in
|
||||
}
|
||||
|
||||
.terminal-reconnect-btn,
|
||||
.terminal-restart-btn {
|
||||
.terminal-restart-btn,
|
||||
.terminal-reopen-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
@@ -1507,7 +1508,8 @@ The shortcut bar (modifier keys + arrow keys) must sit on ONE line, not stack in
|
||||
}
|
||||
|
||||
.terminal-reconnect-btn:hover,
|
||||
.terminal-restart-btn:hover {
|
||||
.terminal-restart-btn:hover,
|
||||
.terminal-reopen-btn:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
@@ -2018,3 +2020,74 @@ FN-7813 embeds the shared terminal inside Task Detail. The host must fill the pa
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
The reopen list must be portaled and fixed-positioned: `.terminal-status-bar` is an overflow-x
|
||||
scroll container, so a popover anchored inside it would be clipped. Positioned from the trigger's
|
||||
rect (inline left/bottom) and stacked with nextFloatingZ so it clears pinned/popped-out terminals.
|
||||
*/
|
||||
.terminal-reopen-menu {
|
||||
position: fixed;
|
||||
max-width: min(calc(var(--space-xl) * 14), calc(100vw - (var(--space-md) * 2)));
|
||||
max-height: min(calc(var(--space-xl) * 12), calc(100dvh - (var(--space-md) * 2)));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: var(--space-xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.terminal-reopen-menu-label {
|
||||
padding: var(--space-sm) var(--space-sm) var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.terminal-reopen-menu-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
width: 100%;
|
||||
padding: var(--space-sm);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-reopen-menu-option:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.terminal-reopen-menu-option-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.terminal-reopen-menu-option-main span,
|
||||
.terminal-reopen-menu-option-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-reopen-menu-option-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
@@ -30,13 +30,15 @@ import {
|
||||
FolderRoot,
|
||||
Pin,
|
||||
PinOff,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import { useTerminal } from "../hooks/useTerminal";
|
||||
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
||||
import { useWorkspaces } from "../hooks/useWorkspaces";
|
||||
import { getViewportMode, isMobileViewport } from "../hooks/useViewportMode";
|
||||
import { FloatingWindow, FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT } from "./FloatingWindow";
|
||||
import { currentFloatingZ } from "./floatingWindowStack";
|
||||
import { currentFloatingZ, nextFloatingZ } from "./floatingWindowStack";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { getPathBasename } from "../utils/pathDisplay";
|
||||
import {
|
||||
DEFAULT_TERMINAL_PREFERENCES,
|
||||
@@ -946,6 +948,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
bootstrapError,
|
||||
createTab,
|
||||
closeTab,
|
||||
detachedSessions,
|
||||
refreshDetachedSessions,
|
||||
reopenSession,
|
||||
setActiveTab,
|
||||
updateTabTitle,
|
||||
restartActiveTab,
|
||||
@@ -956,6 +961,89 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
defaultCwd,
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
Closing a tab is ambiguous once sessions are shared, so it asks rather than guessing: "Close here"
|
||||
detaches this browser and leaves the PTY running (for other viewers, and for the footer's reopen
|
||||
control), while "End session" kills it for everyone. alwaysAsk is set because this GATES an
|
||||
informed choice — auto-resolving under skip-confirmations would silently pick one, and picking
|
||||
wrong either strands a session or destroys someone else's shell.
|
||||
*/
|
||||
const { confirmWithChoice } = useConfirm();
|
||||
const requestCloseTab = useCallback(async (tabId: string): Promise<void> => {
|
||||
const choice = await confirmWithChoice({
|
||||
title: t("terminal.closeTabTitle", "Close this terminal?"),
|
||||
message: t(
|
||||
"terminal.closeTabMessage",
|
||||
"The session keeps running on the server unless you end it. Anyone else viewing it stays connected, and you can reopen it from the terminal footer.",
|
||||
),
|
||||
alwaysAsk: true,
|
||||
confirmLabel: t("terminal.closeTabHere", "Close in this browser"),
|
||||
cancelLabel: t("actions.cancel", "Cancel"),
|
||||
tertiaryLabel: t("terminal.closeTabEndSession", "End session"),
|
||||
tertiaryDanger: true,
|
||||
});
|
||||
if (choice === "cancel") return;
|
||||
const killSession = choice === "tertiary";
|
||||
closeTab(tabId, { killSession });
|
||||
// A detached session becomes reopenable immediately; a killed one must disappear from the list.
|
||||
void refreshDetachedSessions();
|
||||
}, [closeTab, confirmWithChoice, refreshDetachedSessions, t]);
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
The footer's reopen control surfaces sessions running on the server that this browser is not
|
||||
showing — closed here, or opened by someone else. Without it, "close in this browser" would be a
|
||||
one-way door and another person's terminal would be unreachable from this one.
|
||||
*/
|
||||
const [reopenMenuOpen, setReopenMenuOpen] = useState(false);
|
||||
const reopenTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const reopenMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [reopenMenuPosition, setReopenMenuPosition] = useState<{ left: number; bottom: number; minWidth: number } | null>(null);
|
||||
const [reopenMenuZ, setReopenMenuZ] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auxEffectsActive || !isReady) return;
|
||||
void refreshDetachedSessions();
|
||||
}, [auxEffectsActive, isReady, refreshDetachedSessions]);
|
||||
|
||||
const openReopenMenu = useCallback(() => {
|
||||
const rect = reopenTriggerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setReopenMenuPosition({
|
||||
left: rect.left,
|
||||
bottom: Math.max(0, window.innerHeight - rect.top + 6),
|
||||
minWidth: Math.max(rect.width, 240),
|
||||
});
|
||||
}
|
||||
setReopenMenuZ(nextFloatingZ());
|
||||
void refreshDetachedSessions();
|
||||
setReopenMenuOpen(true);
|
||||
}, [refreshDetachedSessions]);
|
||||
|
||||
// Dismiss on outside press or Escape, matching the workspace picker's behaviour.
|
||||
useEffect(() => {
|
||||
if (!reopenMenuOpen) return;
|
||||
const onPointerDown = (event: PointerEvent | MouseEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
if (reopenMenuRef.current?.contains(target) || reopenTriggerRef.current?.contains(target)) return;
|
||||
setReopenMenuOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
setReopenMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
};
|
||||
}, [reopenMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auxEffectsActive) {
|
||||
setTabsOverflow(false);
|
||||
@@ -2343,8 +2431,69 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
FNXC:TerminalFooter 2026-07-11-20:20:
|
||||
FN-7829 keeps the single terminal action-control cluster (reconnect/restart, font-size, Clear, Shortcuts toggle, Preferences toggle, connection status, exit code, and help text) in the bottom `.terminal-status-bar` footer at every breakpoint. Pin/pop-out use their own single header fragment beside close; the header still never renders `.terminal-actions`, preventing handler drift across all presentation modes.
|
||||
*/
|
||||
const reopenSessionControl = detachedSessions.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={reopenTriggerRef}
|
||||
className="terminal-reopen-btn"
|
||||
onClick={() => (reopenMenuOpen ? setReopenMenuOpen(false) : openReopenMenu())}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={reopenMenuOpen}
|
||||
title={t("terminal.reopenSessionTitle", "Reopen a session still running on the server")}
|
||||
data-testid="terminal-reopen-btn"
|
||||
>
|
||||
<History size={14} />
|
||||
<span className="terminal-action-label">
|
||||
{t("terminal.reopenSession", "Reopen")} ({detachedSessions.length})
|
||||
</span>
|
||||
</button>
|
||||
{reopenMenuOpen && createPortal(
|
||||
<div
|
||||
ref={reopenMenuRef}
|
||||
className="terminal-reopen-menu"
|
||||
role="listbox"
|
||||
aria-label={t("terminal.reopenSessionTitle", "Reopen a session still running on the server")}
|
||||
data-testid="terminal-reopen-menu"
|
||||
style={{
|
||||
...(reopenMenuPosition
|
||||
? { left: reopenMenuPosition.left, bottom: reopenMenuPosition.bottom, minWidth: reopenMenuPosition.minWidth }
|
||||
: { visibility: "hidden", pointerEvents: "none" }),
|
||||
...(reopenMenuZ ? { zIndex: reopenMenuZ } : {}),
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="terminal-reopen-menu-label">
|
||||
{t("terminal.reopenSessionHeading", "Running on the server")}
|
||||
</div>
|
||||
{detachedSessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
className="terminal-reopen-menu-option"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => {
|
||||
reopenSession(session.id);
|
||||
setReopenMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="terminal-reopen-menu-option-main">
|
||||
<TerminalIcon size={14} />
|
||||
<span>{session.cwd ? session.cwd.split(/[\\/]+/).filter(Boolean).pop() : session.shell}</span>
|
||||
</span>
|
||||
<span className="terminal-reopen-menu-option-meta">{session.cwd}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const terminalActionControls = (
|
||||
<>
|
||||
{reopenSessionControl}
|
||||
{connectionStatus === "disconnected" && activeTab && (
|
||||
<button
|
||||
className="terminal-reconnect-btn"
|
||||
@@ -2470,7 +2619,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
tabIndex={measuring ? -1 : undefined}
|
||||
onClick={measuring ? undefined : (e: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
void requestCloseTab(tab.id);
|
||||
}}
|
||||
title={t("terminal.closeTab", "Close tab")}
|
||||
>
|
||||
@@ -2529,7 +2678,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-mobile-tab-action terminal-mobile-tab-action--close"
|
||||
onClick={() => closeTab(activeTab.id)}
|
||||
onClick={() => void requestCloseTab(activeTab.id)}
|
||||
title={t("terminal.closeCurrentTab", "Close current tab")}
|
||||
aria-label={t("terminal.closeCurrentTab", "Close current tab")}
|
||||
data-testid="terminal-mobile-close-tab"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
XTERM_FONT_FAMILY,
|
||||
resolveTerminalFontFamily,
|
||||
} from "../../utils/terminalPreferences";
|
||||
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||
import * as useTerminalModule from "../../hooks/useTerminal";
|
||||
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
|
||||
import * as useWorkspacesModule from "../../hooks/useWorkspaces";
|
||||
@@ -356,6 +357,26 @@ describe("evaluateTabsOverflow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
Closing a tab now asks which close was meant, because PTYs are shared: "Close in this browser"
|
||||
detaches and leaves the session running for other viewers, "End session" kills it for everyone.
|
||||
These helpers drive that prompt so the desktop and mobile close controls are both proven to route
|
||||
through it — the mobile control was a second, separate call site that an earlier UI change would
|
||||
have left on the old always-kill path.
|
||||
*/
|
||||
function renderWithConfirm(ui: React.ReactElement) {
|
||||
return render(<ConfirmDialogProvider>{ui}</ConfirmDialogProvider>);
|
||||
}
|
||||
|
||||
async function chooseCloseIntent(intent: "detach" | "end"): Promise<void> {
|
||||
const label = intent === "detach" ? "Close in this browser" : "End session";
|
||||
const button = await screen.findByRole("button", { name: label });
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
});
|
||||
}
|
||||
|
||||
// Default tab state
|
||||
const defaultTab = {
|
||||
id: "tab-1",
|
||||
@@ -378,6 +399,9 @@ const defaultSessionState = {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
};
|
||||
|
||||
describe("TerminalModal", () => {
|
||||
@@ -1974,7 +1998,7 @@ describe("TerminalModal", () => {
|
||||
closeTab: mockCloseTab,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the close button for the zsh tab (second tab)
|
||||
@@ -1985,7 +2009,91 @@ describe("TerminalModal", () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-2");
|
||||
// Detach: the tab goes away here, the PTY keeps running for anyone else attached.
|
||||
await chooseCloseIntent("detach");
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-2", { killSession: false });
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
Detaching would be a one-way door without a way back: the footer control lists sessions the server
|
||||
still runs that this browser is not showing (closed here, or opened by someone else) and reattaches
|
||||
to them rather than starting a new PTY.
|
||||
*/
|
||||
it("offers detached server sessions in the footer and reopens them", async () => {
|
||||
const mockReopenSession = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
detachedSessions: [
|
||||
{ id: "session-detached", cwd: "/project/api", shell: "/bin/bash", createdAt: "2026-08-19T02:00:00.000Z", lastActivityAt: "2026-08-19T02:30:00.000Z" },
|
||||
],
|
||||
reopenSession: mockReopenSession,
|
||||
});
|
||||
|
||||
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const reopenButton = await screen.findByTestId("terminal-reopen-btn");
|
||||
expect(reopenButton).toHaveTextContent("Reopen (1)");
|
||||
|
||||
fireEvent.click(reopenButton);
|
||||
const menu = await screen.findByTestId("terminal-reopen-menu");
|
||||
const option = menu.querySelector<HTMLButtonElement>(".terminal-reopen-menu-option");
|
||||
expect(option?.textContent).toContain("api");
|
||||
|
||||
fireEvent.click(option!);
|
||||
expect(mockReopenSession).toHaveBeenCalledWith("session-detached");
|
||||
});
|
||||
|
||||
it("hides the reopen control when every server session is already open here", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({ ...defaultSessionState, detachedSessions: [] });
|
||||
|
||||
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await screen.findByTestId("terminal-tabs");
|
||||
expect(screen.queryByTestId("terminal-reopen-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("tab close button can end the session for everyone", async () => {
|
||||
const mockCloseTab = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [
|
||||
{ ...defaultTab, isActive: true },
|
||||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||||
],
|
||||
closeTab: mockCloseTab,
|
||||
});
|
||||
|
||||
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const closeButtons = await screen.findAllByTitle("Close tab");
|
||||
fireEvent.click(closeButtons[1]!);
|
||||
|
||||
await chooseCloseIntent("end");
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-2", { killSession: true });
|
||||
});
|
||||
|
||||
it("cancelling the close prompt leaves the tab alone", async () => {
|
||||
const mockCloseTab = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [
|
||||
{ ...defaultTab, isActive: true },
|
||||
{ id: "tab-2", sessionId: "test-session-456", title: "zsh", isActive: false, createdAt: Date.now() },
|
||||
],
|
||||
closeTab: mockCloseTab,
|
||||
});
|
||||
|
||||
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const closeButtons = await screen.findAllByTitle("Close tab");
|
||||
fireEvent.click(closeButtons[1]!);
|
||||
|
||||
const cancel = await screen.findByRole("button", { name: "Cancel" });
|
||||
await act(async () => {
|
||||
fireEvent.click(cancel);
|
||||
});
|
||||
expect(mockCloseTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("new tab button creates new tab", async () => {
|
||||
@@ -2047,7 +2155,7 @@ describe("TerminalModal", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||
|
||||
try {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
const expandedTabs = await screen.findByTestId("terminal-tabs");
|
||||
defineMetric(expandedTabs, "scrollWidth", 360);
|
||||
@@ -2070,7 +2178,8 @@ describe("TerminalModal", () => {
|
||||
expect(mockCreateTab).toHaveBeenCalledWith();
|
||||
expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab");
|
||||
fireEvent.click(screen.getByTestId("terminal-mobile-close-tab"));
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-1");
|
||||
await chooseCloseIntent("detach");
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-1", { killSession: false });
|
||||
|
||||
defineMetric(measuringTabs, "scrollWidth", 199);
|
||||
defineMetric(measuringTabs, "clientWidth", 200);
|
||||
@@ -2141,13 +2250,15 @@ describe("TerminalModal", () => {
|
||||
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
||||
|
||||
try {
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("terminal-mobile-new-tab"));
|
||||
expect(mockCreateTab).toHaveBeenCalledWith();
|
||||
|
||||
// Mobile is a SECOND close call site; it must route through the same prompt as desktop.
|
||||
fireEvent.click(screen.getByLabelText("Close current tab"));
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-1");
|
||||
await chooseCloseIntent("end");
|
||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-1", { killSession: true });
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||||
}
|
||||
@@ -4467,6 +4578,9 @@ describe("TerminalModal — mobile layout contract", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -5426,6 +5540,9 @@ describe("TerminalModal — FN-1234 mobile tab switch with keyboard", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
});
|
||||
|
||||
const createTerminalInstance = (cols: number, rows: number) => ({
|
||||
@@ -5825,6 +5942,9 @@ describe("TerminalModal — virtual keyboard overlap handling", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
};
|
||||
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
@@ -6325,6 +6445,9 @@ describe("TerminalModal — close and reopen scrollback replay", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -6532,6 +6655,9 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
};
|
||||
|
||||
let savedVisualViewport: typeof window.visualViewport;
|
||||
@@ -8255,6 +8381,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn(),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8310,6 +8439,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn(),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
@@ -8345,6 +8477,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn(),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
});
|
||||
|
||||
// Switch project
|
||||
@@ -8392,6 +8527,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn(),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
});
|
||||
|
||||
// Switch to project B
|
||||
@@ -8440,6 +8578,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn(),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
});
|
||||
|
||||
// Switch tab (not project)
|
||||
|
||||
@@ -126,6 +126,9 @@ function setActiveSession(sessionId: string): void {
|
||||
restartActiveTab: vi.fn(),
|
||||
retryBootstrap: vi.fn(),
|
||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||
detachedSessions: [],
|
||||
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||
reopenSession: vi.fn(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
|
||||
@@ -605,6 +605,13 @@ describe("useTerminalSessions", () => {
|
||||
// listTerminalSessions() permanently pending to prove the auto-create
|
||||
// path does not wait on it — asserting observable sequencing, not just
|
||||
// that createTerminalSession was eventually called.
|
||||
//
|
||||
// FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
// The guarantee is now BOUNDED rather than absolute: a fresh open consults the list so it can
|
||||
// adopt sessions other browsers already run (auto-create fires on a 0ms timer, so a
|
||||
// background list could never win that race). A hung list must still not hold the terminal
|
||||
// hostage — it now costs ADOPT_LIST_TIMEOUT_MS (1.5s) and then behaves exactly as before,
|
||||
// which is what the raised waitFor budget below allows for.
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockReturnValue(new Promise(() => {})); // never resolves
|
||||
mockCreateTerminalSession.mockResolvedValue({
|
||||
@@ -618,7 +625,7 @@ describe("useTerminalSessions", () => {
|
||||
// Auto-create must complete even though listTerminalSessions never settles.
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
}, { timeout: 5000 });
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-fast");
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -669,6 +676,182 @@ describe("useTerminalSessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
Terminal PTYs live server-side and already accept several attached viewers, but each browser kept
|
||||
its own tab list in localStorage and a browser with none spawned a private session without ever
|
||||
asking what was already running. Two people on one Fusion (or one person in a second browser)
|
||||
therefore never saw each other's terminals. A fresh client must ADOPT the server's sessions.
|
||||
*/
|
||||
describe("cross-browser session adoption", () => {
|
||||
it("adopts the server's existing sessions instead of spawning a private one", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-b", cwd: "/project/api", shell: "/bin/bash", createdAt: "2026-08-19T02:00:00.000Z", lastActivityAt: "2026-08-19T02:30:00.000Z" },
|
||||
{ id: "session-a", cwd: "/project/web", shell: "/bin/bash", createdAt: "2026-08-19T01:00:00.000Z", lastActivityAt: "2026-08-19T02:30:00.000Z" },
|
||||
]);
|
||||
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-private", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(2);
|
||||
});
|
||||
// Oldest first, so every client orders the shared set identically.
|
||||
expect(result.current.tabs.map((tab) => tab.sessionId)).toEqual(["session-a", "session-b"]);
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-a");
|
||||
expect(result.current.tabs.map((tab) => tab.title)).toEqual(["web", "api"]);
|
||||
// The whole point: no parallel session nobody else can see.
|
||||
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still auto-creates when the server has no sessions to adopt", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-first", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-first");
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to auto-create when the session list fails", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockRejectedValue(new Error("network down"));
|
||||
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-fallback", shell: "/bin/bash", cwd: "/project" });
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tabs.length).toBe(1);
|
||||
});
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-fallback");
|
||||
});
|
||||
|
||||
it("does not adopt into a browser that already has its own tabs", async () => {
|
||||
// Adopting here would resurrect tabs this browser deliberately closed.
|
||||
const storedTabs = [{ id: "tab-1", sessionId: "session-mine", title: "bash", isActive: true, createdAt: Date.now() }];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-mine", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T01:00:00.000Z", lastActivityAt: "2026-08-19T02:30:00.000Z" },
|
||||
{ id: "session-someone-else", cwd: "/project/api", shell: "/bin/bash", createdAt: "2026-08-19T02:00:00.000Z", lastActivityAt: "2026-08-19T02:30:00.000Z" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
expect(result.current.tabs.map((tab) => tab.sessionId)).toEqual(["session-mine"]);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
Closing is two intents: detach (leave the PTY for other viewers and for reopening) versus end the
|
||||
session for everyone. Before this, every close killed the PTY, so closing a tab in one browser
|
||||
yanked the terminal out from under anyone else attached to it.
|
||||
*/
|
||||
describe("detach vs kill on close", () => {
|
||||
const twoTabs = [
|
||||
{ id: "tab-1", sessionId: "session-1", title: "one", isActive: true, createdAt: 1 },
|
||||
{ id: "tab-2", sessionId: "session-2", title: "two", isActive: false, createdAt: 2 },
|
||||
];
|
||||
|
||||
it("leaves the server session running when the tab is only detached", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(twoTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-1", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T01:00:00.000Z", lastActivityAt: "2026-08-19T01:00:00.000Z" },
|
||||
{ id: "session-2", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T02:00:00.000Z", lastActivityAt: "2026-08-19T02:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
await waitFor(() => expect(result.current.tabs.length).toBe(2));
|
||||
|
||||
act(() => {
|
||||
result.current.closeTab("tab-1", { killSession: false });
|
||||
});
|
||||
|
||||
expect(result.current.tabs.map((tab) => tab.sessionId)).toEqual(["session-2"]);
|
||||
expect(mockKillPtyTerminalSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("kills the server session when the operator ends it", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(twoTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-1", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T01:00:00.000Z", lastActivityAt: "2026-08-19T01:00:00.000Z" },
|
||||
{ id: "session-2", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T02:00:00.000Z", lastActivityAt: "2026-08-19T02:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
await waitFor(() => expect(result.current.tabs.length).toBe(2));
|
||||
|
||||
act(() => {
|
||||
result.current.closeTab("tab-1", { killSession: true });
|
||||
});
|
||||
|
||||
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1", TEST_PROJECT_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reopening detached sessions", () => {
|
||||
it("offers server sessions this browser is not showing, and reopens them", async () => {
|
||||
const storedTabs = [{ id: "tab-1", sessionId: "session-mine", title: "mine", isActive: true, createdAt: 1 }];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-mine", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T01:00:00.000Z", lastActivityAt: "2026-08-19T01:00:00.000Z" },
|
||||
{ id: "session-theirs", cwd: "/project/api", shell: "/bin/bash", createdAt: "2026-08-19T02:00:00.000Z", lastActivityAt: "2026-08-19T02:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
await waitFor(() => expect(result.current.isReady).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshDetachedSessions();
|
||||
});
|
||||
|
||||
// Only the session this browser is not showing is offered.
|
||||
expect(result.current.detachedSessions.map((session) => session.id)).toEqual(["session-theirs"]);
|
||||
|
||||
act(() => {
|
||||
result.current.reopenSession("session-theirs");
|
||||
});
|
||||
|
||||
expect(result.current.tabs.map((tab) => tab.sessionId)).toEqual(["session-mine", "session-theirs"]);
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-theirs");
|
||||
// Reopening attaches to the existing PTY rather than starting a new one.
|
||||
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
||||
// And it drops out of the reopen list once it is showing here.
|
||||
expect(result.current.detachedSessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("focuses an already-open tab instead of duplicating it", async () => {
|
||||
const storedTabs = [
|
||||
{ id: "tab-1", sessionId: "session-1", title: "one", isActive: true, createdAt: 1 },
|
||||
{ id: "tab-2", sessionId: "session-2", title: "two", isActive: false, createdAt: 2 },
|
||||
];
|
||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-1", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T01:00:00.000Z", lastActivityAt: "2026-08-19T01:00:00.000Z" },
|
||||
{ id: "session-2", cwd: "/project", shell: "/bin/bash", createdAt: "2026-08-19T02:00:00.000Z", lastActivityAt: "2026-08-19T02:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
await waitFor(() => expect(result.current.tabs.length).toBe(2));
|
||||
|
||||
act(() => {
|
||||
result.current.reopenSession("session-2");
|
||||
});
|
||||
|
||||
expect(result.current.tabs.length).toBe(2);
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("creating additional tabs", () => {
|
||||
it("creates new tab with fresh session when createTab is called", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api";
|
||||
import type { PtyTerminalSessionInfo } from "../api";
|
||||
/*
|
||||
FNXC:CodeOrganization 2026-07-26-07:30:
|
||||
Wave17 moved system-panel under api/system/. useTerminalSessions must import the nested path so Vite/tsc resolve after the domain peel (PR #2398 CI).
|
||||
@@ -11,6 +12,15 @@ const STORAGE_KEY = "kb-terminal-tabs";
|
||||
|
||||
/** Timeout for the list-terminal-sessions validation call during bootstrap. */
|
||||
const BOOTSTRAP_LIST_TIMEOUT_MS = 15000;
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
Adoption must decide BEFORE auto-create fires (auto-create runs on a 0ms timer, so a background list
|
||||
can never win the race), which means a fresh open waits for the listing FN-7686 removed. The wait is
|
||||
capped well below the 15s bootstrap budget: FN-7686's guarantee weakens from "never waits" to "waits
|
||||
at most ADOPT_LIST_TIMEOUT_MS, then behaves exactly as before", so an unreachable or hung server
|
||||
still cannot hold the terminal hostage.
|
||||
*/
|
||||
const ADOPT_LIST_TIMEOUT_MS = 1500;
|
||||
/** Timeout for the auto-create createTerminalSession call during bootstrap. */
|
||||
const BOOTSTRAP_CREATE_TIMEOUT_MS = 15000;
|
||||
/** Timeout for the server-platform probe consulted by Windows browser clients. */
|
||||
@@ -59,8 +69,22 @@ interface UseTerminalSessionsReturn {
|
||||
bootstrapError: string | null;
|
||||
/** Creates a new tab with a fresh server session */
|
||||
createTab: (input?: CreateTerminalTabInput) => Promise<TerminalTab>;
|
||||
/** Closes a specific tab (kills server session) */
|
||||
closeTab: (tabId: string) => void;
|
||||
/**
|
||||
* Closes a specific tab.
|
||||
*
|
||||
* FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
* Closing is two distinct intents now that sessions are shared: DETACH removes the tab from this
|
||||
* browser and leaves the PTY running for other viewers (and for reopening later), while killing
|
||||
* ends it for everyone. `killSession` defaults to true so existing callers keep their old
|
||||
* behaviour; the terminal UI asks the operator which one they meant.
|
||||
*/
|
||||
closeTab: (tabId: string, options?: { killSession?: boolean }) => void;
|
||||
/** Server sessions that are running but not open as a tab in this browser. */
|
||||
detachedSessions: PtyTerminalSessionInfo[];
|
||||
/** Re-query the server for sessions this browser is not showing. */
|
||||
refreshDetachedSessions: () => Promise<void>;
|
||||
/** Reopen a still-running server session as a tab in this browser. */
|
||||
reopenSession: (sessionId: string) => void;
|
||||
/** Switches to a different tab */
|
||||
setActiveTab: (tabId: string) => void;
|
||||
/** Updates the display title of a tab */
|
||||
@@ -217,6 +241,34 @@ function buildTabTitle(input: CreateTerminalTabInput | undefined, terminalNumber
|
||||
return `Terminal ${terminalNumber}`;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
Terminal sessions live on the SERVER (one PTY registry per project root), but the tab list is
|
||||
per-browser localStorage. A browser with no stored tabs used to skip the session listing entirely
|
||||
and spawn its own PTY, so two people on the same Fusion — or the same person in a second browser —
|
||||
never saw each other's terminals and silently accumulated parallel sessions.
|
||||
|
||||
A browser with no tabs now adopts whatever sessions the server already has, oldest first, so every
|
||||
client converges on the same set. Only the zero-tab path adopts: a client with stored tabs keeps
|
||||
validating them as before, because adopting there would resurrect tabs the user deliberately closed
|
||||
in this browser.
|
||||
|
||||
The cost is the round trip FN-7686 removed from cold open, bounded by ADOPT_LIST_TIMEOUT_MS and
|
||||
falling back to auto-create, so a slow or unreachable list degrades to the old behaviour rather than
|
||||
blocking the terminal.
|
||||
*/
|
||||
function adoptServerSessions(sessions: PtyTerminalSessionInfo[]): TerminalTab[] {
|
||||
const ordered = [...sessions].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
return normalizeActiveTab(ordered.map((session, index) => ({
|
||||
id: generateTabId(),
|
||||
sessionId: session.id,
|
||||
title: session.cwd ? titleFromCwd(session.cwd) : (session.shell || `Terminal ${index + 1}`),
|
||||
...(session.cwd ? { cwd: session.cwd } : {}),
|
||||
isActive: index === 0,
|
||||
createdAt: Date.parse(session.createdAt) || Date.now(),
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a promise with a timeout that rejects with a TimeoutError.
|
||||
* Uses an AbortSignal-style approach so only the winning path resolves.
|
||||
@@ -347,7 +399,32 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
the list call below, since its result IS decision-relevant there (which
|
||||
sessionIds still exist server-side).
|
||||
*/
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
Zero stored tabs no longer means "spawn a private terminal". List first and ADOPT whatever the
|
||||
server already runs, so a second browser (or a second person on a shared Fusion) opens onto the
|
||||
same sessions instead of a parallel one nobody else can see. See adoptServerSessions.
|
||||
|
||||
A failed or slow list falls through to auto-create, preserving FN-7686's guarantee that a cold
|
||||
open cannot be blocked by this round trip.
|
||||
*/
|
||||
if (readTabsFromStorage(projectId, storageScope).length === 0) {
|
||||
try {
|
||||
const serverSessions = await withTimeout(
|
||||
listTerminalSessions(projectId),
|
||||
ADOPT_LIST_TIMEOUT_MS,
|
||||
"listTerminalSessions"
|
||||
);
|
||||
if (cancelled || gen !== generationRef.current) return;
|
||||
if (serverSessions.length > 0) {
|
||||
setTabs(adoptServerSessions(serverSessions));
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled || gen !== generationRef.current) return;
|
||||
if (!isRelativeUrlFetchError(err)) {
|
||||
console.warn("Failed to adopt existing terminal sessions:", err);
|
||||
}
|
||||
}
|
||||
if (cancelled || gen !== generationRef.current) return;
|
||||
setServerAvailable(true);
|
||||
setIsReady(true);
|
||||
@@ -563,15 +640,23 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
* If closing the active tab, activates the next or previous tab.
|
||||
* If closing the last tab, auto-creates a new one.
|
||||
*/
|
||||
const closeTab = useCallback((tabId: string): void => {
|
||||
const closeTab = useCallback((tabId: string, options?: { killSession?: boolean }): void => {
|
||||
const killSession = options?.killSession ?? true;
|
||||
setTabs((currentTabs) => {
|
||||
const tabToClose = currentTabs.find((t) => t.id === tabId);
|
||||
if (!tabToClose) return currentTabs;
|
||||
|
||||
// Non-blocking server session kill
|
||||
killPtyTerminalSession(tabToClose.sessionId, projectId).catch((err) => {
|
||||
console.warn(`Failed to kill terminal session ${tabToClose.sessionId}:`, err);
|
||||
});
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
Detaching must leave the PTY alone: another browser may be attached to it, and the footer's
|
||||
reopen control exists to bring it back here. Only an explicit kill ends it for everyone.
|
||||
*/
|
||||
if (killSession) {
|
||||
// Non-blocking server session kill
|
||||
killPtyTerminalSession(tabToClose.sessionId, projectId).catch((err) => {
|
||||
console.warn(`Failed to kill terminal session ${tabToClose.sessionId}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
const tabIndex = currentTabs.findIndex((t) => t.id === tabId);
|
||||
const wasActive = tabToClose.isActive;
|
||||
@@ -594,6 +679,55 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
|
||||
return remainingTabs;
|
||||
});
|
||||
// FNXC:TerminalSharing 2026-08-19-04:10: projectId is read inside (the kill call), so it must be
|
||||
// a dependency — an empty list froze it at the first render's project.
|
||||
}, [projectId]);
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||
Sessions this browser is not showing but the server still runs — either detached here, or started
|
||||
by someone else's browser. The raw server list is stored and the "detached" set derived from it, so
|
||||
opening or closing a tab updates the reopen control without another round trip.
|
||||
*/
|
||||
const [knownServerSessions, setKnownServerSessions] = useState<PtyTerminalSessionInfo[]>([]);
|
||||
const knownServerSessionsRef = useRef<PtyTerminalSessionInfo[]>([]);
|
||||
knownServerSessionsRef.current = knownServerSessions;
|
||||
|
||||
const refreshDetachedSessions = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setKnownServerSessions(await listTerminalSessions(projectId));
|
||||
} catch (err) {
|
||||
if (!isRelativeUrlFetchError(err)) {
|
||||
console.warn("Failed to list terminal sessions:", err);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const detachedSessions = useMemo(() => {
|
||||
const openSessionIds = new Set(tabs.map((tab) => tab.sessionId));
|
||||
return knownServerSessions
|
||||
.filter((session) => !openSessionIds.has(session.id))
|
||||
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
}, [knownServerSessions, tabs]);
|
||||
|
||||
const reopenSession = useCallback((sessionId: string): void => {
|
||||
setTabs((currentTabs) => {
|
||||
if (currentTabs.some((tab) => tab.sessionId === sessionId)) {
|
||||
return normalizeActiveTab(currentTabs.map((tab) => ({ ...tab, isActive: tab.sessionId === sessionId })));
|
||||
}
|
||||
const session = knownServerSessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
if (!session) return currentTabs;
|
||||
|
||||
const reopened: TerminalTab = {
|
||||
id: generateTabId(),
|
||||
sessionId: session.id,
|
||||
title: session.cwd ? titleFromCwd(session.cwd) : (session.shell || "Terminal"),
|
||||
...(session.cwd ? { cwd: session.cwd } : {}),
|
||||
isActive: true,
|
||||
createdAt: Date.parse(session.createdAt) || Date.now(),
|
||||
};
|
||||
return [...currentTabs.map((tab) => ({ ...tab, isActive: false })), reopened];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -731,6 +865,9 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
bootstrapError,
|
||||
createTab,
|
||||
closeTab,
|
||||
detachedSessions,
|
||||
refreshDetachedSessions,
|
||||
reopenSession,
|
||||
setActiveTab,
|
||||
updateTabTitle,
|
||||
restartActiveTab,
|
||||
|
||||
@@ -574,6 +574,36 @@ describe("TerminalService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
Several browsers can watch one PTY, so attaching a viewer must not consume output the already
|
||||
attached viewers have not received. getScrollbackAndClearPending() drops the queue outright, which
|
||||
is invisible with a single viewer and silently deletes a slice of everyone else's live stream once
|
||||
a second one connects. flushPendingOutput() is what an attach uses instead.
|
||||
*/
|
||||
describe("shared-viewer output handoff", () => {
|
||||
it("delivers queued output to existing subscribers instead of discarding it", async () => {
|
||||
const existingViewer = vi.fn();
|
||||
service.onData(existingViewer);
|
||||
|
||||
const createResult = await service.createSession();
|
||||
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
|
||||
const session = createResult.session;
|
||||
|
||||
// Output arrives and is still queued behind the flush throttle when a second viewer attaches.
|
||||
mockPtyProcess._onDataCallback?.("queued output");
|
||||
service.flushPendingOutput(session.id);
|
||||
|
||||
expect(existingViewer).toHaveBeenCalledWith(session.id, "queued output");
|
||||
// The newcomer reads it from scrollback, so it is delivered exactly once to each viewer.
|
||||
expect(service.getScrollback(session.id)).toContain("queued output");
|
||||
});
|
||||
|
||||
it("is a no-op for an unknown session", () => {
|
||||
expect(() => service.flushPendingOutput("no-such-session")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event handling", () => {
|
||||
it("emits data events", async () => {
|
||||
const dataMock = vi.fn();
|
||||
|
||||
@@ -2547,8 +2547,15 @@ export function setupTerminalWebSocket(
|
||||
});
|
||||
}
|
||||
|
||||
// Send scrollback buffer first
|
||||
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
|
||||
/*
|
||||
FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
Terminal sessions are shared: several browsers can watch and drive the same PTY, so an attach
|
||||
must not clear the pending-output queue. Flush it to whoever is already attached FIRST (this
|
||||
socket has not subscribed yet, so it cannot double-receive), then send the scrollback, which now
|
||||
contains those bytes for the newcomer.
|
||||
*/
|
||||
terminalService.flushPendingOutput(sessionId);
|
||||
const scrollback = terminalService.getScrollback(sessionId);
|
||||
if (scrollback) {
|
||||
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
||||
}
|
||||
|
||||
@@ -973,8 +973,38 @@ export class TerminalService extends EventEmitter {
|
||||
return session?.scrollbackBuffer || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain every pending output chunk to the session's subscribers, now.
|
||||
*
|
||||
* FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
* Attaching a viewer must not consume output that other viewers have not received yet. The
|
||||
* throttled flush batches by OUTPUT_BATCH_SIZE and reschedules, so a single call can leave chunks
|
||||
* queued; this loops until the queue is empty (bounded, so a pathological producer cannot spin
|
||||
* the event loop) and is what an attach calls INSTEAD of discarding the queue. Delivering first
|
||||
* also means the scrollback the new viewer is about to receive already contains those bytes, so
|
||||
* it sees them exactly once and existing viewers keep their stream intact.
|
||||
*/
|
||||
flushPendingOutput(sessionId: string): void {
|
||||
if (!this.isValidSessionId(sessionId)) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session?._flushOutput) return;
|
||||
|
||||
const MAX_DRAIN_PASSES = 1000;
|
||||
let passes = 0;
|
||||
while (session.outputChunks.length > 0 && passes < MAX_DRAIN_PASSES) {
|
||||
passes += 1;
|
||||
session._flushOutput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scrollback and clear pending output buffer
|
||||
*
|
||||
* FNXC:TerminalSharing 2026-08-19-03:05:
|
||||
* Discards queued output, so it is only safe when the caller is the session's ONLY consumer.
|
||||
* A viewer attach must use flushPendingOutput() + getScrollback() instead: clearing here while a
|
||||
* second browser watches the same PTY silently deletes a slice of the first browser's live
|
||||
* stream, which is invisible in single-viewer testing.
|
||||
*/
|
||||
getScrollbackAndClearPending(sessionId: string): string | null {
|
||||
if (!this.isValidSessionId(sessionId)) {
|
||||
|
||||
Reference in New Issue
Block a user