From 9f107672548390a1a5fb93241e5a081197833bd4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 19:13:53 -0700 Subject: [PATCH] feat: share terminal sessions across browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/shared-terminal-sessions.md | 7 + docs/dashboard-guide.md | 9 + .../app/components/TerminalModal.css | 77 +++++++- .../app/components/TerminalModal.tsx | 155 ++++++++++++++- .../__tests__/TerminalModal.test.tsx | 153 ++++++++++++++- .../TerminalModal.webgl-disposal.test.tsx | 3 + .../__tests__/useTerminalSessions.test.ts | 185 +++++++++++++++++- .../app/hooks/useTerminalSessions.ts | 153 ++++++++++++++- .../src/__tests__/terminal-service.test.ts | 30 +++ packages/dashboard/src/server.ts | 11 +- packages/dashboard/src/terminal-service.ts | 30 +++ 11 files changed, 791 insertions(+), 22 deletions(-) create mode 100644 .changeset/shared-terminal-sessions.md diff --git a/.changeset/shared-terminal-sessions.md b/.changeset/shared-terminal-sessions.md new file mode 100644 index 0000000000..ec03449150 --- /dev/null +++ b/.changeset/shared-terminal-sessions.md @@ -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()`. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b9513a55db..1cb283b0ca 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. + +### 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. diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css index 72ee4956ff..d6bbaf5497 100644 --- a/packages/dashboard/app/components/TerminalModal.css +++ b/packages/dashboard/app/components/TerminalModal.css @@ -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; +} diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 4cdd4a5f98..1b5cd78b81 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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 => { + 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(null); + const reopenMenuRef = useRef(null); + const [reopenMenuPosition, setReopenMenuPosition] = useState<{ left: number; bottom: number; minWidth: number } | null>(null); + const [reopenMenuZ, setReopenMenuZ] = useState(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 ? ( + <> + + {reopenMenuOpen && createPortal( +
event.stopPropagation()} + > +
+ {t("terminal.reopenSessionHeading", "Running on the server")} +
+ {detachedSessions.map((session) => ( + + ))} +
, + document.body, + )} + + ) : null; + const terminalActionControls = ( <> + {reopenSessionControl} {connectionStatus === "disconnected" && activeTab && (