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. -->
|
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.
|
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.
|
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. -->
|
<!-- 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-reconnect-btn,
|
||||||
.terminal-restart-btn {
|
.terminal-restart-btn,
|
||||||
|
.terminal-reopen-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-xs);
|
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-reconnect-btn:hover,
|
||||||
.terminal-restart-btn:hover {
|
.terminal-restart-btn:hover,
|
||||||
|
.terminal-reopen-btn:hover {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
border-color: var(--text-muted);
|
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;
|
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,
|
FolderRoot,
|
||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
|
History,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTerminal } from "../hooks/useTerminal";
|
import { useTerminal } from "../hooks/useTerminal";
|
||||||
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
import { useTerminalSessions } from "../hooks/useTerminalSessions";
|
||||||
import { useWorkspaces } from "../hooks/useWorkspaces";
|
import { useWorkspaces } from "../hooks/useWorkspaces";
|
||||||
import { getViewportMode, isMobileViewport } from "../hooks/useViewportMode";
|
import { getViewportMode, isMobileViewport } from "../hooks/useViewportMode";
|
||||||
import { FloatingWindow, FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT } from "./FloatingWindow";
|
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 { getPathBasename } from "../utils/pathDisplay";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TERMINAL_PREFERENCES,
|
DEFAULT_TERMINAL_PREFERENCES,
|
||||||
@@ -946,6 +948,9 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
bootstrapError,
|
bootstrapError,
|
||||||
createTab,
|
createTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
detachedSessions,
|
||||||
|
refreshDetachedSessions,
|
||||||
|
reopenSession,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
updateTabTitle,
|
updateTabTitle,
|
||||||
restartActiveTab,
|
restartActiveTab,
|
||||||
@@ -956,6 +961,89 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
defaultCwd,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!auxEffectsActive) {
|
if (!auxEffectsActive) {
|
||||||
setTabsOverflow(false);
|
setTabsOverflow(false);
|
||||||
@@ -2343,8 +2431,69 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
FNXC:TerminalFooter 2026-07-11-20:20:
|
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.
|
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 = (
|
const terminalActionControls = (
|
||||||
<>
|
<>
|
||||||
|
{reopenSessionControl}
|
||||||
{connectionStatus === "disconnected" && activeTab && (
|
{connectionStatus === "disconnected" && activeTab && (
|
||||||
<button
|
<button
|
||||||
className="terminal-reconnect-btn"
|
className="terminal-reconnect-btn"
|
||||||
@@ -2470,7 +2619,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
tabIndex={measuring ? -1 : undefined}
|
tabIndex={measuring ? -1 : undefined}
|
||||||
onClick={measuring ? undefined : (e: ReactMouseEvent<HTMLButtonElement>) => {
|
onClick={measuring ? undefined : (e: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeTab(tab.id);
|
void requestCloseTab(tab.id);
|
||||||
}}
|
}}
|
||||||
title={t("terminal.closeTab", "Close tab")}
|
title={t("terminal.closeTab", "Close tab")}
|
||||||
>
|
>
|
||||||
@@ -2529,7 +2678,7 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="terminal-mobile-tab-action terminal-mobile-tab-action--close"
|
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")}
|
title={t("terminal.closeCurrentTab", "Close current tab")}
|
||||||
aria-label={t("terminal.closeCurrentTab", "Close current tab")}
|
aria-label={t("terminal.closeCurrentTab", "Close current tab")}
|
||||||
data-testid="terminal-mobile-close-tab"
|
data-testid="terminal-mobile-close-tab"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
XTERM_FONT_FAMILY,
|
XTERM_FONT_FAMILY,
|
||||||
resolveTerminalFontFamily,
|
resolveTerminalFontFamily,
|
||||||
} from "../../utils/terminalPreferences";
|
} from "../../utils/terminalPreferences";
|
||||||
|
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||||
import * as useTerminalModule from "../../hooks/useTerminal";
|
import * as useTerminalModule from "../../hooks/useTerminal";
|
||||||
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
|
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
|
||||||
import * as useWorkspacesModule from "../../hooks/useWorkspaces";
|
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
|
// Default tab state
|
||||||
const defaultTab = {
|
const defaultTab = {
|
||||||
id: "tab-1",
|
id: "tab-1",
|
||||||
@@ -378,6 +399,9 @@ const defaultSessionState = {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("TerminalModal", () => {
|
describe("TerminalModal", () => {
|
||||||
@@ -1974,7 +1998,7 @@ describe("TerminalModal", () => {
|
|||||||
closeTab: mockCloseTab,
|
closeTab: mockCloseTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Find the close button for the zsh tab (second tab)
|
// 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 () => {
|
it("new tab button creates new tab", async () => {
|
||||||
@@ -2047,7 +2155,7 @@ describe("TerminalModal", () => {
|
|||||||
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
const expandedTabs = await screen.findByTestId("terminal-tabs");
|
const expandedTabs = await screen.findByTestId("terminal-tabs");
|
||||||
defineMetric(expandedTabs, "scrollWidth", 360);
|
defineMetric(expandedTabs, "scrollWidth", 360);
|
||||||
@@ -2070,7 +2178,8 @@ describe("TerminalModal", () => {
|
|||||||
expect(mockCreateTab).toHaveBeenCalledWith();
|
expect(mockCreateTab).toHaveBeenCalledWith();
|
||||||
expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab");
|
expectTerminalCloseAfterNewTerminal("terminal-mobile-new-tab");
|
||||||
fireEvent.click(screen.getByTestId("terminal-mobile-close-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, "scrollWidth", 199);
|
||||||
defineMetric(measuringTabs, "clientWidth", 200);
|
defineMetric(measuringTabs, "clientWidth", 200);
|
||||||
@@ -2141,13 +2250,15 @@ describe("TerminalModal", () => {
|
|||||||
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
renderWithConfirm(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByTestId("terminal-mobile-new-tab"));
|
fireEvent.click(await screen.findByTestId("terminal-mobile-new-tab"));
|
||||||
expect(mockCreateTab).toHaveBeenCalledWith();
|
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"));
|
fireEvent.click(screen.getByLabelText("Close current tab"));
|
||||||
expect(mockCloseTab).toHaveBeenCalledWith("tab-1");
|
await chooseCloseIntent("end");
|
||||||
|
expect(mockCloseTab).toHaveBeenCalledWith("tab-1", { killSession: true });
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
Object.defineProperty(window, "innerWidth", { value: previousInnerWidth, configurable: true });
|
||||||
}
|
}
|
||||||
@@ -4467,6 +4578,9 @@ describe("TerminalModal — mobile layout contract", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -5426,6 +5540,9 @@ describe("TerminalModal — FN-1234 mobile tab switch with keyboard", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createTerminalInstance = (cols: number, rows: number) => ({
|
const createTerminalInstance = (cols: number, rows: number) => ({
|
||||||
@@ -5825,6 +5942,9 @@ describe("TerminalModal — virtual keyboard overlap handling", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let savedVisualViewport: typeof window.visualViewport;
|
let savedVisualViewport: typeof window.visualViewport;
|
||||||
@@ -6325,6 +6445,9 @@ describe("TerminalModal — close and reopen scrollback replay", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -6532,6 +6655,9 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let savedVisualViewport: typeof window.visualViewport;
|
let savedVisualViewport: typeof window.visualViewport;
|
||||||
@@ -8255,6 +8381,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: 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(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn(),
|
replaceActiveTabSession: vi.fn(),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
@@ -8345,6 +8477,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn(),
|
replaceActiveTabSession: vi.fn(),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch project
|
// Switch project
|
||||||
@@ -8392,6 +8527,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn(),
|
replaceActiveTabSession: vi.fn(),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch to project B
|
// Switch to project B
|
||||||
@@ -8440,6 +8578,9 @@ describe("TerminalModal — project-context propagation (FN-1765)", () => {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn(),
|
replaceActiveTabSession: vi.fn(),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch tab (not project)
|
// Switch tab (not project)
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ function setActiveSession(sessionId: string): void {
|
|||||||
restartActiveTab: vi.fn(),
|
restartActiveTab: vi.fn(),
|
||||||
retryBootstrap: vi.fn(),
|
retryBootstrap: vi.fn(),
|
||||||
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
replaceActiveTabSession: vi.fn().mockResolvedValue(undefined),
|
||||||
|
detachedSessions: [],
|
||||||
|
refreshDetachedSessions: vi.fn().mockResolvedValue(undefined),
|
||||||
|
reopenSession: vi.fn(),
|
||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -605,6 +605,13 @@ describe("useTerminalSessions", () => {
|
|||||||
// listTerminalSessions() permanently pending to prove the auto-create
|
// listTerminalSessions() permanently pending to prove the auto-create
|
||||||
// path does not wait on it — asserting observable sequencing, not just
|
// path does not wait on it — asserting observable sequencing, not just
|
||||||
// that createTerminalSession was eventually called.
|
// 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);
|
localStorageMock.getItem.mockReturnValue(null);
|
||||||
mockListTerminalSessions.mockReturnValue(new Promise(() => {})); // never resolves
|
mockListTerminalSessions.mockReturnValue(new Promise(() => {})); // never resolves
|
||||||
mockCreateTerminalSession.mockResolvedValue({
|
mockCreateTerminalSession.mockResolvedValue({
|
||||||
@@ -618,7 +625,7 @@ describe("useTerminalSessions", () => {
|
|||||||
// Auto-create must complete even though listTerminalSessions never settles.
|
// Auto-create must complete even though listTerminalSessions never settles.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.tabs.length).toBe(1);
|
expect(result.current.tabs.length).toBe(1);
|
||||||
});
|
}, { timeout: 5000 });
|
||||||
expect(result.current.activeTab?.sessionId).toBe("session-fast");
|
expect(result.current.activeTab?.sessionId).toBe("session-fast");
|
||||||
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
|
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", () => {
|
describe("creating additional tabs", () => {
|
||||||
it("creates new tab with fresh session when createTab is called", async () => {
|
it("creates new tab with fresh session when createTab is called", async () => {
|
||||||
localStorageMock.getItem.mockReturnValue(null);
|
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 { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api";
|
||||||
|
import type { PtyTerminalSessionInfo } from "../api";
|
||||||
/*
|
/*
|
||||||
FNXC:CodeOrganization 2026-07-26-07:30:
|
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).
|
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. */
|
/** Timeout for the list-terminal-sessions validation call during bootstrap. */
|
||||||
const BOOTSTRAP_LIST_TIMEOUT_MS = 15000;
|
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. */
|
/** Timeout for the auto-create createTerminalSession call during bootstrap. */
|
||||||
const BOOTSTRAP_CREATE_TIMEOUT_MS = 15000;
|
const BOOTSTRAP_CREATE_TIMEOUT_MS = 15000;
|
||||||
/** Timeout for the server-platform probe consulted by Windows browser clients. */
|
/** Timeout for the server-platform probe consulted by Windows browser clients. */
|
||||||
@@ -59,8 +69,22 @@ interface UseTerminalSessionsReturn {
|
|||||||
bootstrapError: string | null;
|
bootstrapError: string | null;
|
||||||
/** Creates a new tab with a fresh server session */
|
/** Creates a new tab with a fresh server session */
|
||||||
createTab: (input?: CreateTerminalTabInput) => Promise<TerminalTab>;
|
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 */
|
/** Switches to a different tab */
|
||||||
setActiveTab: (tabId: string) => void;
|
setActiveTab: (tabId: string) => void;
|
||||||
/** Updates the display title of a tab */
|
/** Updates the display title of a tab */
|
||||||
@@ -217,6 +241,34 @@ function buildTabTitle(input: CreateTerminalTabInput | undefined, terminalNumber
|
|||||||
return `Terminal ${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.
|
* Wrap a promise with a timeout that rejects with a TimeoutError.
|
||||||
* Uses an AbortSignal-style approach so only the winning path resolves.
|
* 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
|
the list call below, since its result IS decision-relevant there (which
|
||||||
sessionIds still exist server-side).
|
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) {
|
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;
|
if (cancelled || gen !== generationRef.current) return;
|
||||||
setServerAvailable(true);
|
setServerAvailable(true);
|
||||||
setIsReady(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 active tab, activates the next or previous tab.
|
||||||
* If closing the last tab, auto-creates a new one.
|
* 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) => {
|
setTabs((currentTabs) => {
|
||||||
const tabToClose = currentTabs.find((t) => t.id === tabId);
|
const tabToClose = currentTabs.find((t) => t.id === tabId);
|
||||||
if (!tabToClose) return currentTabs;
|
if (!tabToClose) return currentTabs;
|
||||||
|
|
||||||
// Non-blocking server session kill
|
/*
|
||||||
killPtyTerminalSession(tabToClose.sessionId, projectId).catch((err) => {
|
FNXC:TerminalSharing 2026-08-19-04:10:
|
||||||
console.warn(`Failed to kill terminal session ${tabToClose.sessionId}:`, err);
|
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 tabIndex = currentTabs.findIndex((t) => t.id === tabId);
|
||||||
const wasActive = tabToClose.isActive;
|
const wasActive = tabToClose.isActive;
|
||||||
@@ -594,6 +679,55 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
|||||||
|
|
||||||
return remainingTabs;
|
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,
|
bootstrapError,
|
||||||
createTab,
|
createTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
detachedSessions,
|
||||||
|
refreshDetachedSessions,
|
||||||
|
reopenSession,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
updateTabTitle,
|
updateTabTitle,
|
||||||
restartActiveTab,
|
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", () => {
|
describe("event handling", () => {
|
||||||
it("emits data events", async () => {
|
it("emits data events", async () => {
|
||||||
const dataMock = vi.fn();
|
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) {
|
if (scrollback) {
|
||||||
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -973,8 +973,38 @@ export class TerminalService extends EventEmitter {
|
|||||||
return session?.scrollbackBuffer || null;
|
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
|
* 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 {
|
getScrollbackAndClearPending(sessionId: string): string | null {
|
||||||
if (!this.isValidSessionId(sessionId)) {
|
if (!this.isValidSessionId(sessionId)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user