Skip to content

Feat: Persist and synchronize terminals#234

Merged
jamesmurdza merged 19 commits intomainfrom
feat/sync-terminals
Feb 26, 2026
Merged

Feat: Persist and synchronize terminals#234
jamesmurdza merged 19 commits intomainfrom
feat/sync-terminals

Conversation

@jamesmurdza
Copy link
Collaborator

@jamesmurdza jamesmurdza commented Feb 23, 2026

This PR syncs and persists terminal sessions per project. This only happens in server memory, so all sessions will be lost when the server restarts.

This is a necessary first step to have long running agents like Claude Code and OpenCode in the terminal.

Untitled.mov

@jamesmurdza jamesmurdza marked this pull request as draft February 24, 2026 02:24
@jamesmurdza jamesmurdza marked this pull request as ready for review February 24, 2026 12:38
Copy link
Collaborator

@Code-Victor Code-Victor left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM👍🏾

Copy link
Member

@akhileshrangani4 akhileshrangani4 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(superseded by inline review below)

Copy link
Member

@akhileshrangani4 akhileshrangani4 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good direction overall — caching projects server-side and broadcasting terminal events is the right call. Per-terminal resize fix is correct, the RunButtonModal cleanup is way nicer, and deleting that 96-line terminals/index.tsx feels great.

Main blocker: the project cache leaks everything. Fix that before merging — without it the server accumulates zombie projects forever. Rest is good to ship and iterate on.

One thing I couldn't leave as an inline comment since it's outside the diff: web/lib/api/terminal.ts:34 eagerly adds the terminal to local state, then the server broadcasts terminalCreated which triggers TerminalContext.tsx:82-85 to add it again (the prev.some guard catches the dupe). But if createTerminal fails on the server, you've got a ghost terminal in client state with no PTY behind it and no cleanup path.


// Initialize containers and managers
const connections = new ConnectionManager()
const projectCache = new Map<string, Project>()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

projectCache only grows, never shrinks. The disconnect handler on line 170 removes the socket but never calls project.disconnect() or deletes from the cache. That disconnect() method exists at Project.ts:169 and does all the right cleanup (kill dev servers, close terminals, stop file watchers) — but nothing ever invokes it. Every project anyone ever opens stays alive with its container, PTYs, and watchers running until you restart the server.

Consider tracking connection count and cleaning up when it hits zero:

socket.on("disconnect", async () => {
  connections.removeConnectionForProject(socket, data.projectId)
  if (connections.connectionsForProject(data.projectId).length === 0) {
    await project.disconnect()
    projectCache.delete(data.projectId)
  }
})

This will absolutely bite you in prod.

}
return project
})()
projectCreationPromises.set(projectId, promise)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the promise resolves it just sits here forever. Tack on a .finally(() => projectCreationPromises.delete(projectId)) and you're good.

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every new socket calls startWatching with its own callback. If startWatching stacks watchers instead of replacing the callback, you'll get duplicate file notifications multiplied by connection count. Worth checking.

newSocket.on("ready", () => {
setIsReady(true)
// Defer so TerminalContext's useEffect can register listeners first
setTimeout(() => newSocket.emit("getInitialState"), 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're deferring getInitialState with a setTimeout(0) hoping TerminalContext's useEffect has registered its listeners by then. Works today but React doesn't guarantee that — especially in StrictMode or concurrent mode. Cleaner to have TerminalContext emit getInitialState itself after it sets up listeners.

if (buf !== undefined) {
buf += responseString
if (buf.length > MAX_SCREEN_BUFFER_CHARS) {
buf = buf.slice(-MAX_SCREEN_BUFFER_CHARS)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cut could land in the middle of an ANSI escape sequence like \x1B[38;2;255;. When that gets replayed via term.write(initialScreen) at terminal.tsx:134 the terminal parser will freak out — garbled colors, missing text, cursor weirdness. Slice at a newline boundary or scan past partial sequences.

)
}
}, [term])
}, [])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect references socket, id, setTerm, theme, initialScreen from the closure but has [] deps. Linter would yell at this. At least add a suppression comment explaining the intent.

disposableOnResize.dispose()
resizeObserver.disconnect()
}
}, [term, terminalContainerRef.current])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ref .current in a dependency array doesn't trigger re-renders so this dep does nothing. Just remove it to avoid confusion.

}

// Handle stopping the preview server (kills dev server, closes preview terminal)
const handleStopPreview: SocketHandler = async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls terminalManager.closeTerminal() directly and broadcasts terminalClosed itself. But handleCloseTerminal at line 156 also checks if it's the run terminal and clears preview. Same result, two different code paths. Easy to get out of sync on future changes — consider having stopPreview go through handleCloseTerminal instead.

tabComponent: "terminal",
})
if (!isSocketReady || hasAttemptedInitialCreateRef.current) return
const t = setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic 400ms delay to wait for dock refs. Fragile — too short on slow machines, unnecessary latency on fast ones. Key off actual readiness (both refs being set) instead of a hardcoded timeout.

const ref = terminalRef.current
const dock = dockRef.current
const panelId = `terminal-${id}`
if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this exact guard (ref?.getPanel(panelId) || dock?.getPanel(panelId)) is copy-pasted in right-header-actions, usePanelToggles, shortcuts, and here. Pull it into a helper.

@akhileshrangani4 akhileshrangani4 dismissed their stale review February 25, 2026 21:25

Superseded by inline review

@jamesmurdza jamesmurdza merged commit 630ed12 into main Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants