From 69550ceb0acd8e7b3165d28fdf6d93d5e3e07f12 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 09:21:57 -0800 Subject: [PATCH 01/19] chore: delete unused file --- web/components/project/terminals/index.tsx | 96 ---------------------- 1 file changed, 96 deletions(-) delete mode 100644 web/components/project/terminals/index.tsx diff --git a/web/components/project/terminals/index.tsx b/web/components/project/terminals/index.tsx deleted file mode 100644 index d143fb3..0000000 --- a/web/components/project/terminals/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import Tab from "@/components/ui/tab" -import { useSocket } from "@/context/SocketContext" -import { useTerminal } from "@/context/TerminalContext" -import { Terminal } from "@xterm/xterm" -import { Plus, SquareTerminal, TerminalSquare } from "lucide-react" -import { useEffect } from "react" -import { toast } from "sonner" -import EditorTerminal from "./terminal" - -export default function Terminals() { - const { socket } = useSocket() - - const { - terminals, - setTerminals, - createNewTerminal, - closeTerminal, - activeTerminalId, - setActiveTerminalId, - creatingTerminal, - } = useTerminal() - - const activeTerminal = terminals.find((t) => t.id === activeTerminalId) - - // Effect to set the active terminal when a new one is created - useEffect(() => { - if (terminals.length > 0 && !activeTerminalId) { - setActiveTerminalId(terminals[terminals.length - 1].id) - } - }, [terminals, activeTerminalId, setActiveTerminalId]) - - const handleCreateTerminal = () => { - if (terminals.length >= 4) { - toast.error("You reached the maximum # of terminals.") - return - } - createNewTerminal() - } - - return ( - <> -
- {terminals.map((term) => ( - setActiveTerminalId(term.id)} - onClose={() => closeTerminal(term.id)} - selected={activeTerminalId === term.id} - > - - Shell - - ))} - -
- {socket && activeTerminal ? ( -
- {terminals.map((term) => ( - { - setTerminals((prev) => - prev.map((term) => - term.id === activeTerminalId - ? { ...term, terminal: t } - : term, - ), - ) - }} - visible={activeTerminalId === term.id} - /> - ))} -
- ) : ( -
- - No terminals open. -
- )} - - ) -} From 666a8a26933988ddfa7d8ee9d4114edfdd4932ae Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 09:21:49 -0800 Subject: [PATCH 02/19] feat: synchronize terminals between windows in the same project --- lib/services/Project.ts | 20 +++- lib/services/ProjectHandlers.ts | 70 ++++++++++++-- lib/services/TerminalManager.ts | 4 + server/src/index.ts | 35 ++++++- .../project/hooks/useEditorSocket.ts | 92 ++++++++++-------- .../components/right-header-actions.tsx | 3 +- .../layout/components/tab-components.tsx | 18 ++-- web/components/project/layout/index.tsx | 54 ++++++++++- web/components/project/navbar/index.tsx | 6 +- web/components/project/navbar/run.tsx | 96 +++++-------------- web/context/SocketContext.tsx | 2 + web/context/TerminalContext.tsx | 62 ++++++++++++ web/context/editor-context.tsx | 6 +- web/lib/api/terminal.ts | 4 + 14 files changed, 321 insertions(+), 151 deletions(-) diff --git a/lib/services/Project.ts b/lib/services/Project.ts index d9b0c94..7dcf76f 100644 --- a/lib/services/Project.ts +++ b/lib/services/Project.ts @@ -21,10 +21,24 @@ export class Project { container: Container | null = null containerId: string | null = null + // Preview server state (one per project, synced across all clients) + previewURL: string | null = null + runTerminalId: string | null = null + constructor(projectId: string) { this.projectId = projectId } + setPreview(url: string, runTerminalId: string): void { + this.previewURL = url + this.runTerminalId = runTerminalId + } + + clearPreview(): void { + this.previewURL = null + this.runTerminalId = null + } + /** * Kill all known dev server processes in the container (e.g. npm run dev, yarn dev, pnpm dev, vite, next, etc.) */ @@ -110,7 +124,6 @@ export class Project { } async connectToContainer(containerId: string): Promise { - console.log(`Connecting to container ${containerId}`) this.container = await Container.connect(containerId, { timeoutMs: CONTAINER_TIMEOUT, autoPause: true, @@ -144,7 +157,6 @@ export class Project { // Initialize the terminal manager if it hasn't been set up yet if (!this.terminalManager) { this.terminalManager = new TerminalManager(container) - console.log(`Terminal manager set up for ${this.projectId}`) } // Initialize the file manager if it hasn't been set up yet @@ -153,8 +165,10 @@ export class Project { } } - // Called when the client disconnects from the project + // Called when the last client disconnects from the project async disconnect() { + this.clearPreview() + await this.killDevServers() // Close all terminals managed by the terminal manager await this.terminalManager?.closeAllTerminals() // This way the terminal manager will be set up again if we reconnect diff --git a/lib/services/ProjectHandlers.ts b/lib/services/ProjectHandlers.ts index 5a3b859..31d7a2d 100644 --- a/lib/services/ProjectHandlers.ts +++ b/lib/services/ProjectHandlers.ts @@ -1,7 +1,19 @@ import { Socket } from "socket.io" +import { ConnectionManager } from "./ConnectionManager" import { LockManager } from "../utils/lock" import { Project } from "./Project" +function broadcastToProject( + connections: ConnectionManager, + projectId: string, + event: string, + payload: unknown, +) { + connections.connectionsForProject(projectId).forEach((s: Socket) => { + s.emit(event, payload) + }) +} + type ServerContext = { dokkuClient: any | null gitClient: any | null @@ -19,9 +31,11 @@ export const createProjectHandlers = ( project: Project, connection: ConnectionInfo, context: ServerContext, + connections: ConnectionManager, ) => { const { dokkuClient, gitClient } = context const lockManager = new LockManager() + const projectId = project.projectId // Extract port number from a string function extractPortNumber(inputString: string): number | null { @@ -93,23 +107,26 @@ export const createProjectHandlers = ( }: { id: string }) => { - await lockManager.acquireLock(project.projectId, async () => { + await lockManager.acquireLock(projectId, async () => { await project.terminalManager?.createTerminal( id, (responseString: string) => { - connection.socket.emit("terminalResponse", { + broadcastToProject(connections, projectId, "terminalResponse", { id, data: responseString, }) const port = extractPortNumber(responseString) - if (port) { - connection.socket.emit( - "previewURL", - "https://" + project.container?.getHost(port), - ) + if (port && project.container && !project.previewURL) { + const url = "https://" + project.container.getHost(port) + project.setPreview(url, id) + broadcastToProject(connections, projectId, "previewState", { + url, + runTerminalId: id, + }) } }, ) + broadcastToProject(connections, projectId, "terminalCreated", { id }) }) } @@ -134,8 +151,41 @@ export const createProjectHandlers = ( } // Handle closing a terminal - const handleCloseTerminal: SocketHandler = ({ id }: { id: string }) => { - return project.terminalManager?.closeTerminal(id) + const handleCloseTerminal: SocketHandler = async ({ id }: { id: string }) => { + const wasRunTerminal = project.runTerminalId === id + await project.terminalManager?.closeTerminal(id) + broadcastToProject(connections, projectId, "terminalClosed", { id }) + if (wasRunTerminal) { + project.clearPreview() + broadcastToProject(connections, projectId, "previewState", { + url: null, + runTerminalId: null, + }) + } + } + + // Send initial synced state to the requesting client (called when client receives "ready" to avoid race with listener setup) + const handleGetInitialState: SocketHandler = () => { + const ids = project.terminalManager?.getTerminalIds() ?? [] + connection.socket.emit("terminalState", { ids }) + connection.socket.emit("previewState", { + url: project.previewURL, + runTerminalId: project.runTerminalId, + }) + } + + // Handle stopping the preview server (kills dev server, closes preview terminal) + const handleStopPreview: SocketHandler = async () => { + const runId = project.runTerminalId + if (!runId) return + await project.killDevServers() + await project.terminalManager?.closeTerminal(runId) + project.clearPreview() + broadcastToProject(connections, projectId, "terminalClosed", { id: runId }) + broadcastToProject(connections, projectId, "previewState", { + url: null, + runTerminalId: null, + }) } // Return all handlers as a map of event names to handler functions @@ -148,5 +198,7 @@ export const createProjectHandlers = ( resizeTerminal: handleResizeTerminal, terminalData: handleTerminalData, closeTerminal: handleCloseTerminal, + stopPreview: handleStopPreview, + getInitialState: handleGetInitialState, } } diff --git a/lib/services/TerminalManager.ts b/lib/services/TerminalManager.ts index d6e47df..2716439 100644 --- a/lib/services/TerminalManager.ts +++ b/lib/services/TerminalManager.ts @@ -71,4 +71,8 @@ export class TerminalManager { }), ) } + + getTerminalIds(): string[] { + return Object.keys(this.terminals) + } } diff --git a/server/src/index.ts b/server/src/index.ts index 5914436..f11c483 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -40,6 +40,27 @@ process.on("unhandledRejection", (reason, promise) => { // Initialize containers and managers const connections = new ConnectionManager() +const projectCache = new Map() +const projectCreationPromises = new Map>() + +async function getOrCreateProject(projectId: string): Promise { + let existing = projectCache.get(projectId) + if (existing) return existing + let promise = projectCreationPromises.get(projectId) + if (!promise) { + promise = (async () => { + let project = projectCache.get(projectId) + if (!project) { + project = new Project(projectId) + await project.initialize() + projectCache.set(projectId, project) + } + return project + })() + projectCreationPromises.set(projectId, promise) + } + return promise +} // Initialize Express app and create HTTP server const app: Express = express() @@ -107,9 +128,8 @@ io.on("connection", async (socket) => { }) } - // Create or retrieve the project container for the given project ID - const project = new Project(data.projectId) - await project.initialize() + // Get or create project (serialized so concurrent connections share one project) + const project = await getOrCreateProject(data.projectId) await project.fileManager?.startWatching(sendFileNotifications) // Register event handlers for the project @@ -124,6 +144,7 @@ io.on("connection", async (socket) => { dokkuClient, gitClient, }, + connections, ) // For each event handler, listen on the socket for that event @@ -143,13 +164,17 @@ io.on("connection", async (socket) => { }) socket.emit("ready") + // Initial terminal/preview state is sent when client emits getInitialState (avoids race with client listeners) // Handle disconnection event socket.on("disconnect", async () => { try { - // Deregister the connection connections.removeConnectionForProject(socket, data.projectId) - await project.killDevServers() + if (connections.connectionsForProject(data.projectId).size === 0) { + await project.disconnect() + projectCache.delete(data.projectId) + projectCreationPromises.delete(data.projectId) + } } catch (e: any) { handleErrors("Error disconnecting:", e, socket) } diff --git a/web/components/project/hooks/useEditorSocket.ts b/web/components/project/hooks/useEditorSocket.ts index d87149f..28da085 100644 --- a/web/components/project/hooks/useEditorSocket.ts +++ b/web/components/project/hooks/useEditorSocket.ts @@ -5,44 +5,57 @@ import { toast } from "sonner" export const useEditorSocket = () => { const { socket } = useSocket() - const { dockRef } = useEditor() + const { dockRef, loadPreviewURL } = useEditor() - // Preview URL handler - const handlePreviewURL = useCallback((url: string) => { - const previewPanel = dockRef.current?.getPanel("preview") - if (previewPanel) { - previewPanel.api.updateParameters({ src: url }) - previewPanel.api.setActive() - return - } + const openOrUpdatePreviewPanel = useCallback( + (url: string) => { + const previewPanel = dockRef.current?.getPanel("preview") + if (previewPanel) { + previewPanel.api.updateParameters({ src: url }) + previewPanel.api.setActive() + return + } - const groups = dockRef.current?.groups - // If we have exactly one group, split to the right for the preview - // Otherwise open in default location - if (groups?.length === 1) { - dockRef.current?.addPanel({ - id: "preview", - component: "preview", - title: "Preview", - tabComponent: "preview", - params: { src: url }, - renderer: "always", - position: { - referenceGroup: groups[0].id, - direction: "right", - }, - }) - } else { - dockRef.current?.addPanel({ - id: "preview", - component: "preview", - title: "Preview", - renderer: "always", - tabComponent: "preview", - params: { src: url }, - }) - } - }, []) + const groups = dockRef.current?.groups + if (groups?.length === 1) { + dockRef.current?.addPanel({ + id: "preview", + component: "preview", + title: "Preview", + tabComponent: "preview", + params: { src: url }, + renderer: "always", + position: { + referenceGroup: groups[0].id, + direction: "right", + }, + }) + } else { + dockRef.current?.addPanel({ + id: "preview", + component: "preview", + title: "Preview", + renderer: "always", + tabComponent: "preview", + params: { src: url }, + }) + } + }, + [dockRef], + ) + + const handlePreviewState = useCallback( + ({ url }: { url: string | null; runTerminalId: string | null }) => { + loadPreviewURL(url) + if (url) { + openOrUpdatePreviewPanel(url) + } else { + const previewPanel = dockRef.current?.getPanel("preview") + previewPanel?.api.close() + } + }, + [loadPreviewURL, openOrUpdatePreviewPanel, dockRef], + ) // Register socket event listeners useEffect(() => { @@ -52,15 +65,14 @@ export const useEditorSocket = () => { toast.error(message) } - // Register events socket.on("error", onError) - socket.on("previewURL", handlePreviewURL) + socket.on("previewState", handlePreviewState) return () => { socket.off("error", onError) - socket.off("previewURL", handlePreviewURL) + socket.off("previewState", handlePreviewState) } - }, [socket, handlePreviewURL]) + }, [socket, handlePreviewState]) return { socket, diff --git a/web/components/project/layout/components/right-header-actions.tsx b/web/components/project/layout/components/right-header-actions.tsx index 012861a..160381d 100644 --- a/web/components/project/layout/components/right-header-actions.tsx +++ b/web/components/project/layout/components/right-header-actions.tsx @@ -35,8 +35,7 @@ export function TerminalRightHeaderActions(props: IDockviewHeaderActionsProps) { setIsCreating(true) createNewTerminal() .then((id) => { - // add terminal panel - group.panels.at(-1) + if (!id) return containerApi.addPanel({ id: `terminal-${id}`, component: "terminal", diff --git a/web/components/project/layout/components/tab-components.tsx b/web/components/project/layout/components/tab-components.tsx index 038a2e1..e10ac0c 100644 --- a/web/components/project/layout/components/tab-components.tsx +++ b/web/components/project/layout/components/tab-components.tsx @@ -117,35 +117,32 @@ export const tabComponents = { const { api } = props const terminalId = api.id.split("-")[1] // Extract terminal ID from panel ID - const { terminals, setTerminals, setActiveTerminalId } = useTerminal() + const { terminals, closeTerminal, setActiveTerminalId, isPreviewTerminal } = + useTerminal() + const isPreview = isPreviewTerminal(terminalId) const closeActionOverride = React.useCallback(() => { - // Find the terminal and dispose it + if (isPreview) return // Preview terminal can only be closed via Stop const terminal = terminals.find((t) => t.id === terminalId) if (terminal?.terminal) { terminal.terminal.dispose() } - setTerminals((prev) => prev.filter((t) => t.id !== terminalId)) - setActiveTerminalId((prevActiveId) => - prevActiveId === terminalId ? "" : prevActiveId, - ) api.close() - - // If the terminal grid panel will have no terminals after this close, hide it if (terminalRef.current?.panels.length === 1) { const terminalGridPanel = gridRef.current?.getPanel("terminal") if (terminalGridPanel) { terminalGridPanel.api.setVisible(false) } } + closeTerminal(terminalId) }, [ api, terminalId, terminals, - setTerminals, - setActiveTerminalId, + closeTerminal, gridRef, terminalRef, + isPreview, ]) return ( } + hideClose={isPreview} /> ) }, diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index 6424a91..2ea77c7 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -13,7 +13,7 @@ import { themeLight, } from "dockview" import { useTheme } from "next-themes" -import { useCallback, useEffect, type FunctionComponent } from "react" +import { useCallback, useEffect, useRef, type FunctionComponent } from "react" import { useEditorSocket } from "../hooks/useEditorSocket" import { ChatPanel } from "./components/chat-panel" import { EditorPanel } from "./components/editor-panel" @@ -35,7 +35,8 @@ export function Dock(_props: DockProps) { const { resolvedTheme } = useTheme() const { gridRef, dockRef, terminalRef } = useEditor() const { isReady: isSocketReady } = useSocket() - const { creatingTerminal, createNewTerminal } = useTerminal() + const { terminals, creatingTerminal, createNewTerminal } = useTerminal() + const prevTerminalIdsRef = useRef>(new Set()) const chatHandlers = useChatPanelHandlers() useEditorSocket() @@ -180,6 +181,55 @@ export function Dock(_props: DockProps) { } } }, [isSocketReady]) + + // When we have synced terminals (from another window), show terminal panel and add dock panels so tabs appear + useEffect(() => { + if (terminals.length === 0 || !gridRef.current) return + const terminalGridPanel = gridRef.current.getPanel("terminal") + if (terminalGridPanel && !terminalGridPanel.api.isVisible) { + terminalGridPanel.api.setVisible(true) + } + const addPanels = () => { + const ref = terminalRef.current + if (!ref || !dockRef.current) return + terminals.forEach((term) => { + if (!ref.getPanel(`terminal-${term.id}`)) { + ref.addPanel({ + id: `terminal-${term.id}`, + component: "terminal", + title: "Shell", + tabComponent: "terminal", + }) + } + }) + } + if (terminalRef.current && dockRef.current) { + addPanels() + } else { + const t = setTimeout(addPanels, 150) + return () => clearTimeout(t) + } + }, [terminals, gridRef, terminalRef, dockRef]) + + // When a terminal is removed (e.g. terminalClosed from server), close its dock panel so tabs stay in sync + useEffect(() => { + const ref = terminalRef.current + if (!ref) return + const currentIds = new Set(terminals.map((t) => t.id)) + prevTerminalIdsRef.current.forEach((id) => { + if (!currentIds.has(id)) { + ref.getPanel(`terminal-${id}`)?.api.close() + } + }) + prevTerminalIdsRef.current = currentIds + if (terminals.length === 0 && gridRef.current) { + const terminalGridPanel = gridRef.current.getPanel("terminal") + if (terminalGridPanel?.api.isVisible) { + terminalGridPanel.api.setVisible(false) + } + } + }, [terminals, terminalRef, gridRef]) + return (
diff --git a/web/components/project/navbar/run.tsx b/web/components/project/navbar/run.tsx index f6150c0..5b4e6bb 100644 --- a/web/components/project/navbar/run.tsx +++ b/web/components/project/navbar/run.tsx @@ -6,105 +6,53 @@ import { useTerminal } from "@/context/TerminalContext" import { Sandbox } from "@/lib/types" import { templateConfigs } from "@gitwit/templates" import { LoaderCircle, Play, StopCircle } from "lucide-react" -import { useTheme } from "next-themes" -import { useEffect, useRef } from "react" import { toast } from "sonner" export default function RunButtonModal({ isRunning, - setIsRunning, sandboxData, }: { isRunning: boolean - setIsRunning: (running: boolean) => void sandboxData: Sandbox }) { - const { gridRef, dockRef, terminalRef } = useEditor() - const { resolvedTheme } = useTheme() + const { gridRef } = useEditor() const { createNewTerminal, - closeTerminal, + stopPreview, terminals, creatingTerminal, closingTerminal, } = useTerminal() - // Ref to keep track of the last created terminal's ID - const lastCreatedTerminalRef = useRef(null) - // Disable button when creating or closing a terminal const isTransitioning = creatingTerminal || !!closingTerminal - // Effect to update the lastCreatedTerminalRef when a new terminal is added - useEffect(() => { - if (terminals.length > 0 && !isRunning) { - const latestTerminal = terminals[terminals.length - 1] - if ( - latestTerminal && - latestTerminal.id !== lastCreatedTerminalRef.current - ) { - lastCreatedTerminalRef.current = latestTerminal.id - } - } - }, [terminals, isRunning]) - // commands to run in the terminal const handleRun = async () => { - // Guard against rapid clicks during state transitions if (isTransitioning) return - if (isRunning && lastCreatedTerminalRef.current) { - // Stop: Close the terminal (panel will auto-hide if it's the last one) - await closeTerminal(lastCreatedTerminalRef.current) - lastCreatedTerminalRef.current = null - - // Close preview panel if it exists - const previewPanel = dockRef.current?.panels.find( - (panel) => panel.id === "preview", - ) - if (previewPanel) { - previewPanel.api.close() - } - } else if (!isRunning && terminals.length < 4) { - const command = - templateConfigs[sandboxData.type]?.runCommand || "npm run dev" - - try { - // Show terminal panel if hidden - const terminalPanel = gridRef.current?.getPanel("terminal") - if (terminalPanel && !terminalPanel.api.isVisible) { - terminalPanel.api.setVisible(true) - } - - // Create a new terminal with the appropriate command - const terminalId = await createNewTerminal(command) - if (!terminalId) { - throw new Error("Failed to create terminal") - } + if (isRunning) { + stopPreview() + return + } - // Add terminal panel to the terminal container - terminalRef.current?.addPanel({ - id: `terminal-${terminalId}`, - component: "terminal", - title: "Shell", - tabComponent: "terminal", - params: { - dockRef, - terminalRef, - theme: resolvedTheme, - }, - }) - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to create new terminal", - ) - return - } - } else if (!isRunning) { + if (terminals.length >= 4) { toast.error("You've reached the maximum number of terminals.") return } - setIsRunning(!isRunning) + const command = + templateConfigs[sandboxData.type]?.runCommand || "npm run dev" + try { + const terminalPanel = gridRef.current?.getPanel("terminal") + if (terminalPanel && !terminalPanel.api.isVisible) { + terminalPanel.api.setVisible(true) + } + + await createNewTerminal(command) + // Panel is added by the sync effect in terminals/index when state updates + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create terminal", + ) + } } return ( diff --git a/web/context/SocketContext.tsx b/web/context/SocketContext.tsx index f4b9e68..6f40055 100644 --- a/web/context/SocketContext.tsx +++ b/web/context/SocketContext.tsx @@ -32,6 +32,8 @@ export const SocketProvider: React.FC<{ newSocket.on("ready", () => { setIsReady(true) + // Defer so TerminalContext's useEffect can register listeners first + setTimeout(() => newSocket.emit("getInitialState"), 0) }) newSocket.on("disconnect", () => { diff --git a/web/context/TerminalContext.tsx b/web/context/TerminalContext.tsx index e0b70a3..1b5a7e7 100644 --- a/web/context/TerminalContext.tsx +++ b/web/context/TerminalContext.tsx @@ -4,6 +4,7 @@ import { useSocket } from "@/context/SocketContext" import { closeTerminal as closeTerminalHelper, createTerminal as createTerminalHelper, + stopPreview as stopPreviewApi, } from "@/lib/api/terminal" import { MAX_TERMINALS } from "@/lib/constants" import { Terminal } from "@xterm/xterm" @@ -34,8 +35,11 @@ interface TerminalContextType { creatingTerminal: boolean setCreatingTerminal: React.Dispatch> closingTerminal: string + runTerminalId: string | null createNewTerminal: (command?: string) => Promise closeTerminal: (id: string) => Promise + stopPreview: () => void + isPreviewTerminal: (id: string) => boolean deploy: (callback: () => void) => void getAppExists: | ((appName: string) => Promise<{ success: boolean; exists?: boolean }>) @@ -67,6 +71,52 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ const [activeTerminalId, setActiveTerminalId] = useState("") const [creatingTerminal, setCreatingTerminal] = useState(false) const [closingTerminal, setClosingTerminal] = useState("") + const [runTerminalId, setRunTerminalId] = useState(null) + + // Synced from server: terminal list and preview state + useEffect(() => { + if (!socket) return + const onTerminalCreated = ({ id }: { id: string }) => { + setTerminals((prev) => + prev.some((t) => t.id === id) + ? prev + : [...prev, { id, terminal: null, isBusy: false }], + ) + } + const onTerminalClosed = ({ id }: { id: string }) => { + delete commandSentAtRef.current[id] + setTerminals((prev) => prev.filter((t) => t.id !== id)) + setActiveTerminalId((prev) => (prev === id ? "" : prev)) + } + const onTerminalState = ({ ids }: { ids: string[] }) => { + setTerminals((prev) => { + const existing = new Set(prev.map((t) => t.id)) + const toAdd = ids + .filter((id) => !existing.has(id)) + .map((id) => ({ id, terminal: null as Terminal | null, isBusy: false })) + return toAdd.length ? [...prev, ...toAdd] : prev + }) + } + const onPreviewState = ({ + url, + runTerminalId: rid, + }: { + url: string | null + runTerminalId: string | null + }) => { + setRunTerminalId(rid) + } + socket.on("terminalCreated", onTerminalCreated) + socket.on("terminalClosed", onTerminalClosed) + socket.on("terminalState", onTerminalState) + socket.on("previewState", onPreviewState) + return () => { + socket.off("terminalCreated", onTerminalCreated) + socket.off("terminalClosed", onTerminalClosed) + socket.off("terminalState", onTerminalState) + socket.off("previewState", onPreviewState) + } + }, [socket]) // Track when commands were sent to ignore prompts that arrive too quickly // (the existing prompt before command runs) @@ -121,6 +171,15 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ [terminals], ) + const stopPreview = useCallback(() => { + if (socket) stopPreviewApi(socket) + }, [socket]) + + const isPreviewTerminal = useCallback( + (id: string) => runTerminalId === id, + [runTerminalId], + ) + const sendCommandToTerminal = useCallback( (id: string, command: string) => { if (!socket) return @@ -233,8 +292,11 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ creatingTerminal, setCreatingTerminal, closingTerminal, + runTerminalId, createNewTerminal, closeTerminal, + stopPreview, + isPreviewTerminal, deploy, getAppExists: isSocketReady ? getAppExists : null, isTerminalBusy, diff --git a/web/context/editor-context.tsx b/web/context/editor-context.tsx index ed8a976..b37471b 100644 --- a/web/context/editor-context.tsx +++ b/web/context/editor-context.tsx @@ -43,7 +43,7 @@ interface EditorContextType { togglePreviewPanel: () => void toggleLayout: () => void toggleAIChat: () => void - loadPreviewURL: (url: string) => void + loadPreviewURL: (url: string | null) => void setIsAIChatOpen: React.Dispatch> setIsPreviewCollapsed: React.Dispatch> previewPanelRef: React.RefObject @@ -104,8 +104,8 @@ export function EditorProvider({ children }: { children: ReactNode }) { } }, [isAIChatOpen, previousLayout]) - const loadPreviewURL = useCallback((url: string) => { - setPreviewURL(url) + const loadPreviewURL = useCallback((url: string | null) => { + setPreviewURL(url ?? "") }, []) // Handler registry diff --git a/web/lib/api/terminal.ts b/web/lib/api/terminal.ts index 48c7032..3263b2b 100644 --- a/web/lib/api/terminal.ts +++ b/web/lib/api/terminal.ts @@ -118,3 +118,7 @@ export const closeTerminal = ({ }) }) } + +export const stopPreview = (socket: Socket): void => { + socket.emit("stopPreview") +} From b182fafdb1a1d8e9fe5eaa04d73066283208cd50 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 09:32:19 -0800 Subject: [PATCH 03/19] chore: update package-lock.json --- package-lock.json | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/package-lock.json b/package-lock.json index 2658efa..b5ec6de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12961,6 +12961,111 @@ "engines": { "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } + }, + "web/node_modules/@next/swc-darwin-arm64": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz", + "integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-darwin-x64": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz", + "integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz", + "integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-arm64-musl": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz", + "integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-x64-gnu": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz", + "integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-x64-musl": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz", + "integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz", + "integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } From 49790c41fc009859306ee2f01c5bea5f18dd3234 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 09:32:48 -0800 Subject: [PATCH 04/19] feat: retain terminals when the last window is closed --- server/src/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index f11c483..40b6368 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -166,15 +166,10 @@ io.on("connection", async (socket) => { socket.emit("ready") // Initial terminal/preview state is sent when client emits getInitialState (avoids race with client listeners) - // Handle disconnection event + // Handle disconnection event (project and terminals stay in cache so next window sees same state) socket.on("disconnect", async () => { try { connections.removeConnectionForProject(socket, data.projectId) - if (connections.connectionsForProject(data.projectId).size === 0) { - await project.disconnect() - projectCache.delete(data.projectId) - projectCreationPromises.delete(data.projectId) - } } catch (e: any) { handleErrors("Error disconnecting:", e, socket) } From 2caa7275f734c6716f6f71edfa6386582c011e5b Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 09:42:03 -0800 Subject: [PATCH 05/19] fix: only automatically create a new terminal when there are none already --- web/components/project/layout/index.tsx | 36 +++++++++++++------------ 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index 2ea77c7..f75333f 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -37,6 +37,7 @@ export function Dock(_props: DockProps) { const { isReady: isSocketReady } = useSocket() const { terminals, creatingTerminal, createNewTerminal } = useTerminal() const prevTerminalIdsRef = useRef>(new Set()) + const hasAttemptedInitialCreateRef = useRef(false) const chatHandlers = useChatPanelHandlers() useEditorSocket() @@ -162,25 +163,26 @@ export function Dock(_props: DockProps) { } }, [resolvedTheme, gridRef]) + // Create one terminal on first load only if project has none (once per session; don't auto-create after user closes last tab) useEffect(() => { - if (!isSocketReady) return - // create terminal on load if none exist - if (!creatingTerminal && terminalRef.current) { - const existingTerminals = terminalRef.current.panels.length - if (existingTerminals === 0) { - createNewTerminal().then((id) => { - if (!id) return - // add terminal panel - terminalRef.current?.addPanel({ - id: `terminal-${id}`, - component: "terminal", - title: "Shell", - tabComponent: "terminal", - }) + if (!isSocketReady || hasAttemptedInitialCreateRef.current) return + const t = setTimeout(() => { + hasAttemptedInitialCreateRef.current = true + if (creatingTerminal || terminals.length > 0) return + if (!terminalRef.current) return + if (terminalRef.current.panels.length > 0) return + createNewTerminal().then((id) => { + if (!id) return + terminalRef.current?.addPanel({ + id: `terminal-${id}`, + component: "terminal", + title: "Shell", + tabComponent: "terminal", }) - } - } - }, [isSocketReady]) + }) + }, 400) + return () => clearTimeout(t) + }, [isSocketReady, terminals.length, creatingTerminal]) // When we have synced terminals (from another window), show terminal panel and add dock panels so tabs appear useEffect(() => { From 74a051ac1614a52ef4fbde1f3c17375b8e176b3c Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 09:45:05 -0800 Subject: [PATCH 06/19] fix: use correct event name for resize --- web/components/project/terminals/terminal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 05d24b8..21d4c5e 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -131,7 +131,7 @@ export default function EditorTerminal({ const disposableOnResize = term.onResize((dimensions) => { fitAddonRef.current?.fit() - socket.emit("terminalResize", { dimensions }) + socket.emit("resizeTerminal", { dimensions }) }) const resizeObserver = new ResizeObserver( debounce((entries) => { From cab4fbccc628d0369cd908862278f9c20ddd716d Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 09:48:29 -0800 Subject: [PATCH 07/19] fix: resize PTYs individually --- lib/services/ProjectHandlers.ts | 6 ++++-- lib/services/TerminalManager.ts | 12 +++++------- web/components/project/terminals/terminal.tsx | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/services/ProjectHandlers.ts b/lib/services/ProjectHandlers.ts index 31d7a2d..65fed57 100644 --- a/lib/services/ProjectHandlers.ts +++ b/lib/services/ProjectHandlers.ts @@ -130,13 +130,15 @@ export const createProjectHandlers = ( }) } - // Handle resizing a terminal + // Handle resizing a terminal (per-terminal dimensions) const handleResizeTerminal: SocketHandler = ({ + id, dimensions, }: { + id: string dimensions: { cols: number; rows: number } }) => { - project.terminalManager?.resizeTerminal(dimensions) + project.terminalManager?.resizeTerminal(id, dimensions) } // Handle sending data to a terminal diff --git a/lib/services/TerminalManager.ts b/lib/services/TerminalManager.ts index 2716439..6fd0267 100644 --- a/lib/services/TerminalManager.ts +++ b/lib/services/TerminalManager.ts @@ -37,13 +37,11 @@ export class TerminalManager { console.log("Created terminal", id) } - async resizeTerminal(dimensions: { - cols: number - rows: number - }): Promise { - Object.values(this.terminals).forEach((t) => { - t.resize(dimensions) - }) + async resizeTerminal( + id: string, + dimensions: { cols: number; rows: number }, + ): Promise { + this.terminals[id]?.resize(dimensions) } async sendTerminalData(id: string, data: string): Promise { diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 21d4c5e..2f02116 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -131,7 +131,7 @@ export default function EditorTerminal({ const disposableOnResize = term.onResize((dimensions) => { fitAddonRef.current?.fit() - socket.emit("resizeTerminal", { dimensions }) + socket.emit("resizeTerminal", { id, dimensions }) }) const resizeObserver = new ResizeObserver( debounce((entries) => { From e1a8bb6a87aa3039c176a41aea09e015ee4a365f Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 10:05:36 -0800 Subject: [PATCH 08/19] feat: update PTY size when active terminal changes --- .../project/layout/components/terminal-panel.tsx | 15 ++++++++++++--- web/components/project/terminals/terminal.tsx | 9 +++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/web/components/project/layout/components/terminal-panel.tsx b/web/components/project/layout/components/terminal-panel.tsx index dc68942..0cf815f 100644 --- a/web/components/project/layout/components/terminal-panel.tsx +++ b/web/components/project/layout/components/terminal-panel.tsx @@ -5,7 +5,7 @@ import { useTerminal } from "@/context/TerminalContext" import { Terminal } from "@xterm/xterm" import { IDockviewPanelProps } from "dockview" import { Loader2 } from "lucide-react" -import { useEffect } from "react" +import { useEffect, useState } from "react" import EditorTerminal from "../../terminals/terminal" export interface TerminalPanelParams {} @@ -17,12 +17,20 @@ export function TerminalPanel(props: IDockviewPanelProps) { const term = terminals.find((t) => t.id === terminalId) + const [isActive, setIsActive] = useState(() => props.api.isActive) + useEffect(() => { + const disposable = props.api.onDidActiveChange(() => { + setIsActive(props.api.isActive) + }) + return () => disposable.dispose() + }, [props.api]) + // Auto-focus terminal when it becomes active useEffect(() => { - if (term) { + if (term && isActive) { term.terminal?.focus() } - }, [term]) + }, [term, isActive]) if (!term || !socket || !isReady) { return ( @@ -48,6 +56,7 @@ export function TerminalPanel(props: IDockviewPanelProps) { ) }} visible + isActive={isActive} /> ) } diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 2f02116..68288f7 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -16,12 +16,14 @@ export default function EditorTerminal({ term, setTerm, visible, + isActive = false, }: { socket: Socket id: string term: Terminal | null setTerm: (term: Terminal) => void visible: boolean + isActive?: boolean }) { const { resolvedTheme: theme } = useTheme() const terminalContainerRef = useRef>(null) @@ -99,6 +101,13 @@ export default function EditorTerminal({ } }, [theme]) + // When this terminal becomes the active panel, fit and send resize so server PTY matches + useEffect(() => { + if (isActive && fitAddonRef.current) { + fitAddonRef.current.fit() + } + }, [isActive]) + useEffect(() => { if (!term) return From 625880fbe5c7ae6566de1f4068e66bdac9c1b573 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 10:07:59 -0800 Subject: [PATCH 09/19] feat: update PTY size when terminal is focused --- web/components/project/terminals/terminal.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 68288f7..dafb83a 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -142,6 +142,13 @@ export default function EditorTerminal({ fitAddonRef.current?.fit() socket.emit("resizeTerminal", { id, dimensions }) }) + + const handleFocus = () => { + fitAddonRef.current?.fit() + } + const el = term.element + el?.addEventListener("focus", handleFocus) + const resizeObserver = new ResizeObserver( debounce((entries) => { if (!fitAddonRef.current || !terminalContainerRef.current) return @@ -166,6 +173,7 @@ export default function EditorTerminal({ resizeObserver.observe(terminalContainerRef.current) return () => { + el?.removeEventListener("focus", handleFocus) disposableOnData.dispose() disposableOnResize.dispose() resizeObserver.disconnect() From 9ca0d56e4d6b7bc8c090de39fe123dbc5c78f3cf Mon Sep 17 00:00:00 2001 From: James Murdza Date: Mon, 23 Feb 2026 13:51:26 -0800 Subject: [PATCH 10/19] feat: register terminalResponse handler before creating terminals --- web/components/project/terminals/terminal.tsx | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index dafb83a..5475331 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -29,6 +29,7 @@ export default function EditorTerminal({ const terminalContainerRef = useRef>(null) const fitAddonRef = useRef(null) + // Run once on mount: create terminal, register terminalResponse before setTerm (avoid missing first prompt), then cleanup only on unmount useEffect(() => { if (!terminalContainerRef.current) return @@ -83,17 +84,24 @@ export default function EditorTerminal({ return true }) + // Register terminalResponse before setTerm so we don't miss the initial prompt (same-window race) + const handleTerminalResponse = (response: { id: string; data: string }) => { + if (response.id === id) { + terminal.write(response.data) + } + } + socket.on("terminalResponse", handleTerminalResponse) + setTerm(terminal) return () => { - // Don't dispose terminal on unmount - it may be reused after panel move - // Terminal disposal is handled explicitly in closeTerminal + socket.off("terminalResponse", handleTerminalResponse) terminalContainerRef.current?.removeEventListener( "contextmenu", handleContextMenu, ) } - }, [term]) + }, []) useEffect(() => { if (term) { @@ -180,20 +188,6 @@ export default function EditorTerminal({ } }, [term, terminalContainerRef.current]) - useEffect(() => { - if (!term) return - const handleTerminalResponse = (response: { id: string; data: string }) => { - if (response.id === id) { - term.write(response.data) - } - } - socket.on("terminalResponse", handleTerminalResponse) - - return () => { - socket.off("terminalResponse", handleTerminalResponse) - } - }, [term, id, socket]) - return ( <>
Date: Mon, 23 Feb 2026 14:09:06 -0800 Subject: [PATCH 11/19] feat: buffer terminal screen state to show in newly opened windows --- lib/services/ProjectHandlers.ts | 4 ++- lib/services/TerminalManager.ts | 29 ++++++++++++++++++- .../layout/components/terminal-panel.tsx | 1 + web/components/project/terminals/terminal.tsx | 10 +++++++ web/context/TerminalContext.tsx | 17 +++++++++-- 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lib/services/ProjectHandlers.ts b/lib/services/ProjectHandlers.ts index 65fed57..b066d0b 100644 --- a/lib/services/ProjectHandlers.ts +++ b/lib/services/ProjectHandlers.ts @@ -169,7 +169,9 @@ export const createProjectHandlers = ( // Send initial synced state to the requesting client (called when client receives "ready" to avoid race with listener setup) const handleGetInitialState: SocketHandler = () => { const ids = project.terminalManager?.getTerminalIds() ?? [] - connection.socket.emit("terminalState", { ids }) + const screens = + project.terminalManager?.getScreenBuffers?.() ?? undefined + connection.socket.emit("terminalState", { ids, screens }) connection.socket.emit("previewState", { url: project.previewURL, runTerminalId: project.runTerminalId, diff --git a/lib/services/TerminalManager.ts b/lib/services/TerminalManager.ts index 6fd0267..1896126 100644 --- a/lib/services/TerminalManager.ts +++ b/lib/services/TerminalManager.ts @@ -1,9 +1,12 @@ import { Sandbox as Container } from "e2b" import { Terminal } from "./Terminal" +const MAX_SCREEN_BUFFER_CHARS = 50_000 + export class TerminalManager { private container: Container private terminals: Record = {} + private screenBuffers: Record = {} constructor(container: Container) { this.container = container @@ -17,9 +20,22 @@ export class TerminalManager { return } + this.screenBuffers[id] = "" + const pushData = (responseString: string) => { + let buf = this.screenBuffers[id] + if (buf !== undefined) { + buf += responseString + if (buf.length > MAX_SCREEN_BUFFER_CHARS) { + buf = buf.slice(-MAX_SCREEN_BUFFER_CHARS) + } + this.screenBuffers[id] = buf + } + onData(responseString) + } + this.terminals[id] = new Terminal(this.container) await this.terminals[id].init({ - onData, + onData: pushData, cols: 80, rows: 20, }) @@ -59,6 +75,7 @@ export class TerminalManager { await this.terminals[id].close() delete this.terminals[id] + delete this.screenBuffers[id] } async closeAllTerminals(): Promise { @@ -66,6 +83,7 @@ export class TerminalManager { Object.entries(this.terminals).map(async ([key, terminal]) => { await terminal.close() delete this.terminals[key] + delete this.screenBuffers[key] }), ) } @@ -73,4 +91,13 @@ export class TerminalManager { getTerminalIds(): string[] { return Object.keys(this.terminals) } + + getScreenBuffers(): Record { + const out: Record = {} + for (const id of Object.keys(this.terminals)) { + const buf = this.screenBuffers[id] + if (buf) out[id] = buf + } + return out + } } diff --git a/web/components/project/layout/components/terminal-panel.tsx b/web/components/project/layout/components/terminal-panel.tsx index 0cf815f..3804c2e 100644 --- a/web/components/project/layout/components/terminal-panel.tsx +++ b/web/components/project/layout/components/terminal-panel.tsx @@ -57,6 +57,7 @@ export function TerminalPanel(props: IDockviewPanelProps) { }} visible isActive={isActive} + initialScreen={term.initialScreen} /> ) } diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 5475331..81c057b 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -17,6 +17,7 @@ export default function EditorTerminal({ setTerm, visible, isActive = false, + initialScreen, }: { socket: Socket id: string @@ -24,10 +25,12 @@ export default function EditorTerminal({ setTerm: (term: Terminal) => void visible: boolean isActive?: boolean + initialScreen?: string }) { const { resolvedTheme: theme } = useTheme() const terminalContainerRef = useRef>(null) const fitAddonRef = useRef(null) + const hasWrittenInitialScreenRef = useRef(false) // Run once on mount: create terminal, register terminalResponse before setTerm (avoid missing first prompt), then cleanup only on unmount useEffect(() => { @@ -126,6 +129,13 @@ export default function EditorTerminal({ term.open(terminalContainerRef.current) fitAddon.fit() fitAddonRef.current = fitAddon + if ( + initialScreen && + !hasWrittenInitialScreenRef.current + ) { + term.write(initialScreen) + hasWrittenInitialScreenRef.current = true + } } else { // Terminal already opened - reattach to new container const terminalElement = term.element diff --git a/web/context/TerminalContext.tsx b/web/context/TerminalContext.tsx index 1b5a7e7..460746e 100644 --- a/web/context/TerminalContext.tsx +++ b/web/context/TerminalContext.tsx @@ -25,6 +25,8 @@ interface TerminalState { id: string terminal: Terminal | null isBusy: boolean + /** Cached screen from server for newly opened windows */ + initialScreen?: string } interface TerminalContextType { @@ -88,12 +90,23 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ setTerminals((prev) => prev.filter((t) => t.id !== id)) setActiveTerminalId((prev) => (prev === id ? "" : prev)) } - const onTerminalState = ({ ids }: { ids: string[] }) => { + const onTerminalState = ({ + ids, + screens, + }: { + ids: string[] + screens?: Record + }) => { setTerminals((prev) => { const existing = new Set(prev.map((t) => t.id)) const toAdd = ids .filter((id) => !existing.has(id)) - .map((id) => ({ id, terminal: null as Terminal | null, isBusy: false })) + .map((id) => ({ + id, + terminal: null as Terminal | null, + isBusy: false, + initialScreen: screens?.[id], + })) return toAdd.length ? [...prev, ...toAdd] : prev }) } From 8ae99a6b4bc4f0f9b6106281f10aa7fb2a33f7a2 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 04:09:08 -0800 Subject: [PATCH 12/19] chore: simplify resize notification code --- web/components/project/terminals/terminal.tsx | 53 ++++++++----------- 1 file changed, 21 insertions(+), 32 deletions(-) diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 81c057b..30715b2 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -31,6 +31,7 @@ export default function EditorTerminal({ const terminalContainerRef = useRef>(null) const fitAddonRef = useRef(null) const hasWrittenInitialScreenRef = useRef(false) + const fitAndNotifyRef = useRef<(() => void) | null>(null) // Run once on mount: create terminal, register terminalResponse before setTerm (avoid missing first prompt), then cleanup only on unmount useEffect(() => { @@ -114,9 +115,7 @@ export default function EditorTerminal({ // When this terminal becomes the active panel, fit and send resize so server PTY matches useEffect(() => { - if (isActive && fitAddonRef.current) { - fitAddonRef.current.fit() - } + if (isActive) fitAndNotifyRef.current?.() }, [isActive]) useEffect(() => { @@ -145,10 +144,7 @@ export default function EditorTerminal({ ) { // Move the terminal DOM element to the new container terminalContainerRef.current.appendChild(terminalElement) - // Refit after reattachment - setTimeout(() => { - fitAddonRef.current?.fit() - }, 10) + setTimeout(() => fitAndNotifyRef.current?.(), 10) } } @@ -156,41 +152,34 @@ export default function EditorTerminal({ socket.emit("terminalData", { id, data }) }) - const disposableOnResize = term.onResize((dimensions) => { - fitAddonRef.current?.fit() - socket.emit("resizeTerminal", { id, dimensions }) - }) - - const handleFocus = () => { - fitAddonRef.current?.fit() + const fitAndNotify = () => { + if (!fitAddonRef.current) return + try { + fitAddonRef.current.fit() + socket.emit("resizeTerminal", { + id, + dimensions: { cols: term.cols, rows: term.rows }, + }) + } catch (err) { + console.error("Error during fit:", err) + } } + fitAndNotifyRef.current = fitAndNotify + + const disposableOnResize = term.onResize(() => fitAndNotify()) + const handleFocus = () => fitAndNotify() const el = term.element el?.addEventListener("focus", handleFocus) const resizeObserver = new ResizeObserver( - debounce((entries) => { - if (!fitAddonRef.current || !terminalContainerRef.current) return - - const entry = entries[0] - if (!entry) return - - const { width, height } = entry.contentRect - - if ( - width !== terminalContainerRef.current.offsetWidth || - height !== terminalContainerRef.current.offsetHeight - ) { - try { - fitAddonRef.current.fit() - } catch (err) { - console.error("Error during fit:", err) - } - } + debounce(() => { + if (fitAddonRef.current && terminalContainerRef.current) fitAndNotify() }, 50), ) resizeObserver.observe(terminalContainerRef.current) return () => { + fitAndNotifyRef.current = null el?.removeEventListener("focus", handleFocus) disposableOnData.dispose() disposableOnResize.dispose() From 63d6b8a59383d341592f1a4c21a4bd5cebcad750 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 04:14:32 -0800 Subject: [PATCH 13/19] fix: guard addPanel from adding terminal panels with duplicate IDs --- .../project/layout/components/right-header-actions.tsx | 1 + web/components/project/layout/hooks/usePanelToggles.ts | 4 +++- web/components/project/layout/index.tsx | 4 +++- web/components/project/layout/utils/shortcuts.tsx | 9 ++++++--- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/web/components/project/layout/components/right-header-actions.tsx b/web/components/project/layout/components/right-header-actions.tsx index 160381d..eb5474e 100644 --- a/web/components/project/layout/components/right-header-actions.tsx +++ b/web/components/project/layout/components/right-header-actions.tsx @@ -36,6 +36,7 @@ export function TerminalRightHeaderActions(props: IDockviewHeaderActionsProps) { createNewTerminal() .then((id) => { if (!id) return + if (containerApi.getPanel(`terminal-${id}`)) return containerApi.addPanel({ id: `terminal-${id}`, component: "terminal", diff --git a/web/components/project/layout/hooks/usePanelToggles.ts b/web/components/project/layout/hooks/usePanelToggles.ts index 398c6da..6ecd5f4 100644 --- a/web/components/project/layout/hooks/usePanelToggles.ts +++ b/web/components/project/layout/hooks/usePanelToggles.ts @@ -30,7 +30,9 @@ export function useToggleTerminal() { if (!existingTerminals && !creatingTerminal) { createNewTerminal().then((id) => { if (!id) return - terminalRef.current?.addPanel({ + const ref = terminalRef.current + if (ref?.getPanel(`terminal-${id}`)) return + ref?.addPanel({ id: `terminal-${id}`, component: "terminal", title: "Shell", diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index f75333f..5ab6654 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -173,7 +173,9 @@ export function Dock(_props: DockProps) { if (terminalRef.current.panels.length > 0) return createNewTerminal().then((id) => { if (!id) return - terminalRef.current?.addPanel({ + const ref = terminalRef.current + if (ref?.getPanel(`terminal-${id}`)) return + ref?.addPanel({ id: `terminal-${id}`, component: "terminal", title: "Shell", diff --git a/web/components/project/layout/utils/shortcuts.tsx b/web/components/project/layout/utils/shortcuts.tsx index 124c804..4052744 100644 --- a/web/components/project/layout/utils/shortcuts.tsx +++ b/web/components/project/layout/utils/shortcuts.tsx @@ -46,7 +46,9 @@ export function useGlobalShortcut() { if (!creatingTerminal) { createNewTerminal().then((id) => { if (!id) return - terminalRef.current?.addPanel({ + const ref = terminalRef.current + if (ref?.getPanel(`terminal-${id}`)) return + ref?.addPanel({ id: `terminal-${id}`, component: "terminal", title: "Shell", @@ -140,8 +142,9 @@ export function enableEditorShortcuts({ if (!isCreatingTerminal) { createNewTerminal().then((id) => { if (!id) return - // add terminal panel - terminalRef.current?.addPanel({ + const ref = terminalRef.current + if (ref?.getPanel(`terminal-${id}`)) return + ref?.addPanel({ id: `terminal-${id}`, component: "terminal", title: "Shell", From e9ab39549e5b66621ec51c4a4543c7e60fc0f458 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 04:27:42 -0800 Subject: [PATCH 14/19] fix: add debounce to terminal resize handler --- web/components/project/terminals/terminal.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 30715b2..a6fd182 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -156,26 +156,29 @@ export default function EditorTerminal({ if (!fitAddonRef.current) return try { fitAddonRef.current.fit() - socket.emit("resizeTerminal", { - id, - dimensions: { cols: term.cols, rows: term.rows }, - }) + notifyResizeDebounced() } catch (err) { console.error("Error during fit:", err) } } + const notifyResizeDebounced = debounce(() => { + socket.emit("resizeTerminal", { + id, + dimensions: { cols: term.cols, rows: term.rows }, + }) + }, 50) + const fitAndNotifyDebounced = debounce(fitAndNotify, 50) fitAndNotifyRef.current = fitAndNotify - const disposableOnResize = term.onResize(() => fitAndNotify()) + const disposableOnResize = term.onResize(() => notifyResizeDebounced()) const handleFocus = () => fitAndNotify() const el = term.element el?.addEventListener("focus", handleFocus) - const resizeObserver = new ResizeObserver( - debounce(() => { - if (fitAddonRef.current && terminalContainerRef.current) fitAndNotify() - }, 50), - ) + const resizeObserver = new ResizeObserver(() => { + if (fitAddonRef.current && terminalContainerRef.current) + fitAndNotifyDebounced() + }) resizeObserver.observe(terminalContainerRef.current) return () => { From 0ebbcc6658a86cd24f6a5914ee45af0b748780bd Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 07:14:58 -0800 Subject: [PATCH 15/19] fix: check terminal container and dock when moving terminals --- web/components/project/layout/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index 5ab6654..ab17643 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -195,11 +195,14 @@ export function Dock(_props: DockProps) { } const addPanels = () => { const ref = terminalRef.current - if (!ref || !dockRef.current) return + const dock = dockRef.current + if (!ref || !dock) return terminals.forEach((term) => { - if (!ref.getPanel(`terminal-${term.id}`)) { + const id = `terminal-${term.id}` + // Don't add if already in terminal container or if user moved it to the dock + if (!ref.getPanel(id) && !dock.getPanel(id)) { ref.addPanel({ - id: `terminal-${term.id}`, + id, component: "terminal", title: "Shell", tabComponent: "terminal", From 553c3b61e7649b4a7095de0e39feb5e0e90acb81 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 07:40:10 -0800 Subject: [PATCH 16/19] fix: prevent terminal dock from re-opening when all terminals are in main dock --- web/components/project/layout/index.tsx | 38 ++++++++++++++++--------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index ab17643..9e71c9d 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -62,13 +62,12 @@ export function Dock(_props: DockProps) { }) if (result.handled) { - // If terminal container is now empty, hide the terminal grid panel - if (terminalRef.current?.panels.length === 0) { - const terminalGridPanel = gridRef.current?.getPanel("terminal") - if (terminalGridPanel) { - terminalGridPanel.api.setVisible(false) + // Terminal was moved from terminal dock to somewhere else → hide terminal dock when empty + queueMicrotask(() => { + if (terminalRef.current?.panels.length === 0) { + gridRef.current?.getPanel("terminal")?.api.setVisible(false) } - } + }) } }, [terminalRef, dockRef, gridRef], @@ -186,20 +185,30 @@ export function Dock(_props: DockProps) { return () => clearTimeout(t) }, [isSocketReady, terminals.length, creatingTerminal]) - // When we have synced terminals (from another window), show terminal panel and add dock panels so tabs appear + // When we have synced terminals (from another window), show terminal panel and add dock panels so tabs appear. Don't show the strip if all terminals are in the main dock (user moved them). useEffect(() => { if (terminals.length === 0 || !gridRef.current) return - const terminalGridPanel = gridRef.current.getPanel("terminal") - if (terminalGridPanel && !terminalGridPanel.api.isVisible) { - terminalGridPanel.api.setVisible(true) + const ref = terminalRef.current + const dock = dockRef.current + const maybeShowTerminalStrip = () => { + const hasPanelsInTerminalDock = (terminalRef.current?.panels.length ?? 0) > 0 + const allInMainDock = terminals.every( + (t) => dockRef.current?.getPanel(`terminal-${t.id}`) != null, + ) + const terminalGridPanel = gridRef.current?.getPanel("terminal") + if ( + terminalGridPanel && + !terminalGridPanel.api.isVisible && + hasPanelsInTerminalDock && + !allInMainDock + ) { + terminalGridPanel.api.setVisible(true) + } } const addPanels = () => { - const ref = terminalRef.current - const dock = dockRef.current if (!ref || !dock) return terminals.forEach((term) => { const id = `terminal-${term.id}` - // Don't add if already in terminal container or if user moved it to the dock if (!ref.getPanel(id) && !dock.getPanel(id)) { ref.addPanel({ id, @@ -209,8 +218,9 @@ export function Dock(_props: DockProps) { }) } }) + maybeShowTerminalStrip() } - if (terminalRef.current && dockRef.current) { + if (ref && dock) { addPanels() } else { const t = setTimeout(addPanels, 150) From f9349b001f8d6870ca2c2c90e102dbe944711dc1 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 07:22:03 -0800 Subject: [PATCH 17/19] fix: consider both docks when adding/removing terminal panels --- web/components/project/hooks/useEditor.ts | 4 +++- .../layout/components/right-header-actions.tsx | 18 +++++++++--------- .../project/layout/hooks/usePanelToggles.ts | 17 ++++++++++------- web/components/project/layout/index.tsx | 15 ++++++++++----- .../project/layout/utils/shortcuts.tsx | 14 ++++++++++---- 5 files changed, 42 insertions(+), 26 deletions(-) diff --git a/web/components/project/hooks/useEditor.ts b/web/components/project/hooks/useEditor.ts index ec7db51..ba8b9b3 100644 --- a/web/components/project/hooks/useEditor.ts +++ b/web/components/project/hooks/useEditor.ts @@ -40,7 +40,7 @@ export interface DecorationsState { export const useEditor = ({ projectId, fileId }: UseEditorProps) => { const { saveFile, fileTree: files = [] } = useFileTree() - const { terminalRef, gridRef } = useEditorContext() + const { terminalRef, dockRef, gridRef } = useEditorContext() const { creatingTerminal, createNewTerminal } = useTerminal() const draft = useAppStore((s) => s.drafts[fileId ?? ""]) const { data: serverFileContent = "" } = fileRouter.fileContent.useQuery({ @@ -267,6 +267,7 @@ export const useEditor = ({ projectId, fileId }: UseEditorProps) => { monaco: editorRef, gridRef, terminalRef, + dockRef, isCreatingTerminal: creatingTerminal, createNewTerminal, saveFile: () => { @@ -285,6 +286,7 @@ export const useEditor = ({ projectId, fileId }: UseEditorProps) => { saveFile, gridRef, terminalRef, + dockRef, draft, serverFileContent, creatingTerminal, diff --git a/web/components/project/layout/components/right-header-actions.tsx b/web/components/project/layout/components/right-header-actions.tsx index eb5474e..3676abd 100644 --- a/web/components/project/layout/components/right-header-actions.tsx +++ b/web/components/project/layout/components/right-header-actions.tsx @@ -1,6 +1,7 @@ "use client" import { Button } from "@/components/ui/button" +import { useEditor } from "@/context/editor-context" import { useSocket } from "@/context/SocketContext" import { useTerminal } from "@/context/TerminalContext" import { MAX_TERMINALS } from "@/lib/constants" @@ -15,19 +16,17 @@ import { toast } from "sonner" */ export function TerminalRightHeaderActions(props: IDockviewHeaderActionsProps) { const { group, containerApi } = props + const { dockRef } = useEditor() const { isReady: isSocketReady } = useSocket() const { createNewTerminal } = useTerminal() const [isCreating, setIsCreating] = useState(false) const handleAddTerminal = () => { - // Count existing terminal panels across all groups - const allTerminalPanels = containerApi.panels.filter((p) => - p.id.startsWith("terminal-"), - ) - - // Check max terminals limit - if (allTerminalPanels.length >= MAX_TERMINALS) { + // Count existing terminal panels across this container and the dock (user may have moved some) + const here = containerApi.panels.filter((p) => p.id.startsWith("terminal-")).length + const inDock = dockRef.current?.panels.filter((p) => p.id.startsWith("terminal-")).length ?? 0 + if (here + inDock >= MAX_TERMINALS) { toast.error("You reached the maximum # of terminals.") return } @@ -36,9 +35,10 @@ export function TerminalRightHeaderActions(props: IDockviewHeaderActionsProps) { createNewTerminal() .then((id) => { if (!id) return - if (containerApi.getPanel(`terminal-${id}`)) return + const panelId = `terminal-${id}` + if (containerApi.getPanel(panelId) || dockRef.current?.getPanel(panelId)) return containerApi.addPanel({ - id: `terminal-${id}`, + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", diff --git a/web/components/project/layout/hooks/usePanelToggles.ts b/web/components/project/layout/hooks/usePanelToggles.ts index 6ecd5f4..5b751cd 100644 --- a/web/components/project/layout/hooks/usePanelToggles.ts +++ b/web/components/project/layout/hooks/usePanelToggles.ts @@ -14,7 +14,7 @@ export function useToggleSidebar() { } export function useToggleTerminal() { - const { gridRef, terminalRef } = useEditor() + const { gridRef, terminalRef, dockRef } = useEditor() const { creatingTerminal, createNewTerminal } = useTerminal() const { isReady: isSocketReady } = useSocket() @@ -26,14 +26,17 @@ export function useToggleTerminal() { panel.api.setVisible(!isVisible) if (!isVisible && isSocketReady) { - const existingTerminals = Boolean(terminalRef.current?.panels.length) - if (!existingTerminals && !creatingTerminal) { + const ref = terminalRef.current + const dock = dockRef.current + const existingInTerminal = ref?.panels.length ?? 0 + const existingInDock = dock?.panels.filter((p) => p.id.startsWith("terminal-")).length ?? 0 + if (existingInTerminal === 0 && existingInDock === 0 && !creatingTerminal) { createNewTerminal().then((id) => { if (!id) return - const ref = terminalRef.current - if (ref?.getPanel(`terminal-${id}`)) return + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return ref?.addPanel({ - id: `terminal-${id}`, + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", @@ -41,7 +44,7 @@ export function useToggleTerminal() { }) } } - }, [gridRef, terminalRef, isSocketReady, creatingTerminal, createNewTerminal]) + }, [gridRef, terminalRef, dockRef, isSocketReady, creatingTerminal, createNewTerminal]) } export function useToggleChat() { diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index 9e71c9d..60d55b2 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -173,9 +173,11 @@ export function Dock(_props: DockProps) { createNewTerminal().then((id) => { if (!id) return const ref = terminalRef.current - if (ref?.getPanel(`terminal-${id}`)) return + const dock = dockRef.current + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return ref?.addPanel({ - id: `terminal-${id}`, + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", @@ -228,14 +230,17 @@ export function Dock(_props: DockProps) { } }, [terminals, gridRef, terminalRef, dockRef]) - // When a terminal is removed (e.g. terminalClosed from server), close its dock panel so tabs stay in sync + // When a terminal is removed (e.g. terminalClosed from server), close its panel in both containers so tabs stay in sync useEffect(() => { const ref = terminalRef.current + const dock = dockRef.current if (!ref) return const currentIds = new Set(terminals.map((t) => t.id)) prevTerminalIdsRef.current.forEach((id) => { if (!currentIds.has(id)) { - ref.getPanel(`terminal-${id}`)?.api.close() + const panelId = `terminal-${id}` + ref.getPanel(panelId)?.api.close() + dock?.getPanel(panelId)?.api.close() } }) prevTerminalIdsRef.current = currentIds @@ -245,7 +250,7 @@ export function Dock(_props: DockProps) { terminalGridPanel.api.setVisible(false) } } - }, [terminals, terminalRef, gridRef]) + }, [terminals, terminalRef, dockRef, gridRef]) return (
diff --git a/web/components/project/layout/utils/shortcuts.tsx b/web/components/project/layout/utils/shortcuts.tsx index 4052744..254b2cf 100644 --- a/web/components/project/layout/utils/shortcuts.tsx +++ b/web/components/project/layout/utils/shortcuts.tsx @@ -47,9 +47,11 @@ export function useGlobalShortcut() { createNewTerminal().then((id) => { if (!id) return const ref = terminalRef.current - if (ref?.getPanel(`terminal-${id}`)) return + const dock = dockRef.current + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return ref?.addPanel({ - id: `terminal-${id}`, + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", @@ -100,6 +102,7 @@ export function enableEditorShortcuts({ monaco, gridRef, terminalRef, + dockRef, createNewTerminal, isCreatingTerminal, saveFile, @@ -107,6 +110,7 @@ export function enableEditorShortcuts({ monaco: editor.IStandaloneCodeEditor gridRef: MutableRefObject terminalRef: MutableRefObject + dockRef: MutableRefObject createNewTerminal: () => Promise isCreatingTerminal: boolean saveFile: () => void @@ -143,9 +147,11 @@ export function enableEditorShortcuts({ createNewTerminal().then((id) => { if (!id) return const ref = terminalRef.current - if (ref?.getPanel(`terminal-${id}`)) return + const dock = dockRef.current + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return ref?.addPanel({ - id: `terminal-${id}`, + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", From 093820216914d3815b3afeaa735c91b1af8811fe Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 07:53:29 -0800 Subject: [PATCH 18/19] fix: always restore existing terminals when the page loads --- web/components/project/layout/index.tsx | 77 +++++++++++-------------- 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index 60d55b2..c1afa03 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -89,6 +89,36 @@ export function Dock(_props: DockProps) { [dockRef, terminalRef], ) + // Sync terminal tabs to dock panels. Run when terminals change or when either dock becomes ready (avoids refresh race). + const syncTerminalPanels = useCallback(() => { + if (terminals.length === 0) return + const ref = terminalRef.current + const dock = dockRef.current + if (!ref || !dock) return + terminals.forEach((term) => { + const id = `terminal-${term.id}` + if (!ref.getPanel(id) && !dock.getPanel(id)) { + ref.addPanel({ + id, + component: "terminal", + title: "Shell", + tabComponent: "terminal", + }) + } + }) + const hasPanelsInTerminalDock = ref.panels.length > 0 + const allInMainDock = terminals.every((t) => dock.getPanel(`terminal-${t.id}`) != null) + const terminalGridPanel = gridRef.current?.getPanel("terminal") + if ( + terminalGridPanel && + !terminalGridPanel.api.isVisible && + hasPanelsInTerminalDock && + !allInMainDock + ) { + terminalGridPanel.api.setVisible(true) + } + }, [terminals, gridRef, terminalRef, dockRef]) + // components const dockComponents: PanelCollection = { terminal: TerminalPanel, @@ -110,8 +140,8 @@ export function Dock(_props: DockProps) { components={dockComponents} onReady={(event) => { dockRef.current = event.api - // Set up handler for external drag events (from file explorer) event.api.onUnhandledDragOverEvent(handleDockUnhandledDragOver) + syncTerminalPanels() }} onDidDrop={handleDockDidDrop} /> @@ -130,8 +160,8 @@ export function Dock(_props: DockProps) { rightHeaderActionsComponent={TerminalRightHeaderActions} onReady={(event) => { terminalRef.current = event.api - // Accept drags from dock to allow terminal panels back event.api.onUnhandledDragOverEvent(handleDockUnhandledDragOver) + syncTerminalPanels() }} onDidDrop={handleTerminalDidDrop} /> @@ -187,48 +217,9 @@ export function Dock(_props: DockProps) { return () => clearTimeout(t) }, [isSocketReady, terminals.length, creatingTerminal]) - // When we have synced terminals (from another window), show terminal panel and add dock panels so tabs appear. Don't show the strip if all terminals are in the main dock (user moved them). useEffect(() => { - if (terminals.length === 0 || !gridRef.current) return - const ref = terminalRef.current - const dock = dockRef.current - const maybeShowTerminalStrip = () => { - const hasPanelsInTerminalDock = (terminalRef.current?.panels.length ?? 0) > 0 - const allInMainDock = terminals.every( - (t) => dockRef.current?.getPanel(`terminal-${t.id}`) != null, - ) - const terminalGridPanel = gridRef.current?.getPanel("terminal") - if ( - terminalGridPanel && - !terminalGridPanel.api.isVisible && - hasPanelsInTerminalDock && - !allInMainDock - ) { - terminalGridPanel.api.setVisible(true) - } - } - const addPanels = () => { - if (!ref || !dock) return - terminals.forEach((term) => { - const id = `terminal-${term.id}` - if (!ref.getPanel(id) && !dock.getPanel(id)) { - ref.addPanel({ - id, - component: "terminal", - title: "Shell", - tabComponent: "terminal", - }) - } - }) - maybeShowTerminalStrip() - } - if (ref && dock) { - addPanels() - } else { - const t = setTimeout(addPanels, 150) - return () => clearTimeout(t) - } - }, [terminals, gridRef, terminalRef, dockRef]) + syncTerminalPanels() + }, [syncTerminalPanels]) // When a terminal is removed (e.g. terminalClosed from server), close its panel in both containers so tabs stay in sync useEffect(() => { From 0adad6a83e3ae433cc4b078b126c5e8e653cec90 Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 24 Feb 2026 08:15:03 -0800 Subject: [PATCH 19/19] fix: attempt to fix terminals freezing when moved into main dock --- web/components/project/terminals/terminal.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index a6fd182..f0e223f 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -33,14 +33,22 @@ export default function EditorTerminal({ const hasWrittenInitialScreenRef = useRef(false) const fitAndNotifyRef = useRef<(() => void) | null>(null) - // Run once on mount: create terminal, register terminalResponse before setTerm (avoid missing first prompt), then cleanup only on unmount + // Always listen for terminal output (needed for both new terminals and after panel move) useEffect(() => { - if (!terminalContainerRef.current) return - - // If terminal already exists (e.g., from a panel move), skip creation - if (term) { - return + if (!term) return + const handleTerminalResponse = (response: { id: string; data: string }) => { + if (response.id === id) term.write(response.data) + } + socket.on("terminalResponse", handleTerminalResponse) + return () => { + socket.off("terminalResponse", handleTerminalResponse) } + }, [term, id, socket]) + + // Run once on mount: create terminal if needed (skip when term exists, e.g. after panel move) + useEffect(() => { + if (!terminalContainerRef.current) return + if (term) return const terminal = new Terminal({ cursorBlink: true, @@ -88,18 +96,9 @@ export default function EditorTerminal({ return true }) - // Register terminalResponse before setTerm so we don't miss the initial prompt (same-window race) - const handleTerminalResponse = (response: { id: string; data: string }) => { - if (response.id === id) { - terminal.write(response.data) - } - } - socket.on("terminalResponse", handleTerminalResponse) - setTerm(terminal) return () => { - socket.off("terminalResponse", handleTerminalResponse) terminalContainerRef.current?.removeEventListener( "contextmenu", handleContextMenu,