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 && (