diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8f7caedc6e5..8ec8711d7cd 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -153,8 +153,12 @@ app.on("open-url", async (event, url) => { let isQuitting = false; let skipQuitConfirmation = false; -export function quitApp(): void { +export function setSkipQuitConfirmation(): void { skipQuitConfirmation = true; +} + +export function quitApp(): void { + setSkipQuitConfirmation(); app.quit(); } diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 9ada1934eeb..675fe995a8a 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import { app, dialog } from "electron"; import { autoUpdater } from "electron-updater"; import { env } from "main/env.main"; +import { setSkipQuitConfirmation } from "main/index"; import { prerelease } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 67ebf559812..a43935805c8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -50,32 +50,58 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { ); const initialThemeType = initialThemeTypeRef.current; - const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { - workspaceId, - themeType: initialThemeType, - }); + // URL is stable — no workspaceId/themeType in query params. + // Session is created via tRPC before WebSocket connects. + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`); + + const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); + const ensureSessionRef = useRef(ensureSession); + ensureSessionRef.current = ensureSession; const connectionState = useSyncExternalStore( subscribeToState(terminalId), () => getConnectionState(terminalId), ); - // Appearance read from ref to avoid re-attach on theme/font change. useEffect(() => { const container = containerRef.current; if (!container) return; - terminalRuntimeRegistry.attach( - terminalId, - container, - websocketUrl, - appearanceRef.current, - ); + let cancelled = false; + + // Create session via tRPC, then connect WebSocket as data pipe. + ensureSessionRef.current + .mutateAsync({ + terminalId, + workspaceId, + themeType: initialThemeType, + }) + .then(() => { + if (cancelled) return; + terminalRuntimeRegistry.attach( + terminalId, + container, + websocketUrl, + appearanceRef.current, + ); + }) + .catch((err) => { + if (cancelled) return; + console.error("[TerminalPane] ensureSession failed:", err); + // Still try to connect — WS handler has fallback for existing sessions + terminalRuntimeRegistry.attach( + terminalId, + container, + websocketUrl, + appearanceRef.current, + ); + }); return () => { + cancelled = true; terminalRuntimeRegistry.detach(terminalId); }; - }, [terminalId, websocketUrl]); + }, [terminalId, websocketUrl, initialThemeType, workspaceId]); useEffect(() => { terminalRuntimeRegistry.updateAppearance(terminalId, appearance); diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 602186260a8..1d9c23e329f 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -18,7 +18,7 @@ interface RegisterWorkspaceTerminalRouteOptions { upgradeWebSocket: NodeWebSocket["upgradeWebSocket"]; } -function parseThemeType( +export function parseThemeType( value: string | null | undefined, ): "dark" | "light" | undefined { return value === "dark" || value === "light" ? value : undefined; @@ -110,7 +110,7 @@ interface CreateTerminalSessionOptions { db: HostDb; } -function createTerminalSessionInternal({ +export function createTerminalSessionInternal({ terminalId, workspaceId, themeType, @@ -293,8 +293,6 @@ export function registerWorkspaceTerminalRoute({ "/terminal/:terminalId", upgradeWebSocket((c) => { const terminalId = c.req.param("terminalId") ?? ""; - const workspaceId = c.req.query("workspaceId") ?? null; - const themeType = parseThemeType(c.req.query("themeType")); return { onOpen: (_event, ws) => { @@ -304,56 +302,61 @@ export function registerWorkspaceTerminalRoute({ } const existing = sessions.get(terminalId); - if (existing) { - if (existing.socket && existing.socket !== ws) { - existing.socket.close(4000, "Displaced by new connection"); - } - existing.socket = ws; - - db.update(terminalSessions) - .set({ lastAttachedAt: Date.now() }) - .where(eq(terminalSessions.id, terminalId)) - .run(); - - replayBuffer(existing, ws); - if (existing.exited) { + if (!existing) { + // Session must be created via tRPC terminal.ensureSession before connecting. + // Fall back to query params for backwards compatibility with v1 callers. + const workspaceId = c.req.query("workspaceId") ?? null; + if (!workspaceId) { sendMessage(ws, { - type: "exit", - exitCode: existing.exitCode, - signal: existing.exitSignal, + type: "error", + message: + "Session not found. Call terminal.ensureSession first.", }); + ws.close(1011, "Session not found"); + return; } - return; - } - if (!workspaceId) { - sendMessage(ws, { - type: "error", - message: "Missing workspaceId for new terminal session", + const themeType = parseThemeType(c.req.query("themeType")); + const result = createTerminalSessionInternal({ + terminalId, + workspaceId, + themeType, + db, }); - ws.close(1011, "Missing workspaceId"); - return; - } - const result = createTerminalSessionInternal({ - terminalId, - workspaceId, - themeType, - db, - }); + if ("error" in result) { + sendMessage(ws, { type: "error", message: result.error }); + ws.close(1011, result.error); + return; + } + + result.socket = ws; - if ("error" in result) { - sendMessage(ws, { type: "error", message: result.error }); - ws.close(1011, result.error); + db.update(terminalSessions) + .set({ lastAttachedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); return; } - result.socket = ws; + if (existing.socket && existing.socket !== ws) { + existing.socket.close(4000, "Displaced by new connection"); + } + existing.socket = ws; db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) .where(eq(terminalSessions.id, terminalId)) .run(); + + replayBuffer(existing, ws); + if (existing.exited) { + sendMessage(ws, { + type: "exit", + exitCode: existing.exitCode, + signal: existing.exitSignal, + }); + } }, onMessage: (event, ws) => { diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index 95e57ff632a..2c02ba316f9 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -7,6 +7,7 @@ import { githubRouter } from "./github"; import { healthRouter } from "./health"; import { projectRouter } from "./project"; import { pullRequestsRouter } from "./pull-requests"; +import { terminalRouter } from "./terminal"; import { workspaceRouter } from "./workspace"; export const appRouter = router({ @@ -18,6 +19,7 @@ export const appRouter = router({ cloud: cloudRouter, pullRequests: pullRequestsRouter, project: projectRouter, + terminal: terminalRouter, workspace: workspaceRouter, }); diff --git a/packages/host-service/src/trpc/router/terminal/index.ts b/packages/host-service/src/trpc/router/terminal/index.ts new file mode 100644 index 00000000000..08a9b9990f4 --- /dev/null +++ b/packages/host-service/src/trpc/router/terminal/index.ts @@ -0,0 +1 @@ +export { terminalRouter } from "./terminal"; diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts new file mode 100644 index 00000000000..d6f2bec8de0 --- /dev/null +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { + createTerminalSessionInternal, + parseThemeType, +} from "../../../terminal/terminal"; +import { protectedProcedure, router } from "../../index"; + +export const terminalRouter = router({ + ensureSession: protectedProcedure + .input( + z.object({ + terminalId: z.string(), + workspaceId: z.string(), + themeType: z.string().optional(), + }), + ) + .mutation(({ ctx, input }) => { + const result = createTerminalSessionInternal({ + terminalId: input.terminalId, + workspaceId: input.workspaceId, + themeType: parseThemeType(input.themeType), + db: ctx.db, + }); + + if ("error" in result) { + return { + terminalId: input.terminalId, + status: "error" as const, + error: result.error, + }; + } + + return { terminalId: result.terminalId, status: "active" as const }; + }), +});