Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/scripts/patch-dev-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ export function resolveWorkspaceIdentity(
: undefined;
const displayWorkspaceName = resolvedDisplayName || workspaceName;
const bundleDisplayWorkspaceName =
displayWorkspaceName.replaceAll("/", "-").trim() || workspaceName;
displayWorkspaceName
.replaceAll("/", "-")
.replaceAll(/[^a-zA-Z0-9 -]/g, "")
.trim() || workspaceName;

return {
workspaceName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,32 +65,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);
Expand Down
81 changes: 42 additions & 39 deletions packages/host-service/src/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,7 +110,7 @@ interface CreateTerminalSessionOptions {
db: HostDb;
}

function createTerminalSessionInternal({
export function createTerminalSessionInternal({
terminalId,
workspaceId,
themeType,
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/host-service/src/trpc/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -18,6 +19,7 @@ export const appRouter = router({
cloud: cloudRouter,
pullRequests: pullRequestsRouter,
project: projectRouter,
terminal: terminalRouter,
workspace: workspaceRouter,
});

Expand Down
1 change: 1 addition & 0 deletions packages/host-service/src/trpc/router/terminal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { terminalRouter } from "./terminal";
35 changes: 35 additions & 0 deletions packages/host-service/src/trpc/router/terminal/terminal.ts
Original file line number Diff line number Diff line change
@@ -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 };
}),
});