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:
gsxdsm
2026-08-18 19:13:53 -07:00
parent 0289d26b13
commit 9f10767254
11 changed files with 791 additions and 22 deletions

View 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()`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)) {