From f8d0ee4ff03b8832714cac64e3ddaa644eb9e790 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 9 Apr 2026 14:46:22 -0500 Subject: [PATCH] Reduce terminal session startup latency - Resolve terminal env directly from thread state instead of full snapshots - Cache missing terminal history reads and add launch/open instrumentation - Keep terminal drawer mounted and show launch status while opening --- .../Layers/ProjectionSnapshotQuery.ts | 14 +- apps/server/src/serverLayers.ts | 2 + .../src/terminal/Layers/Manager.test.ts | 18 +- apps/server/src/terminal/Layers/Manager.ts | 60 +++++ .../src/terminal/Layers/RuntimeEnvResolver.ts | 16 ++ .../Services/RuntimeEnvResolver.test.ts | 111 ++++++++ .../terminal/Services/RuntimeEnvResolver.ts | 54 ++++ apps/server/src/wsServer.test.ts | 52 +++- apps/server/src/wsServer.ts | 43 +-- apps/web/src/components/ChatView.tsx | 122 ++++++++- .../components/ThreadTerminalDrawer.test.ts | 27 ++ .../src/components/ThreadTerminalDrawer.tsx | 82 +++++- apps/web/src/routes/__root.tsx | 2 + .../web/src/terminalSessionController.test.ts | 196 ++++++++++++++ apps/web/src/terminalSessionController.ts | 253 ++++++++++++++++++ 15 files changed, 1018 insertions(+), 34 deletions(-) create mode 100644 apps/server/src/terminal/Layers/RuntimeEnvResolver.ts create mode 100644 apps/server/src/terminal/Services/RuntimeEnvResolver.test.ts create mode 100644 apps/server/src/terminal/Services/RuntimeEnvResolver.ts create mode 100644 apps/web/src/terminalSessionController.test.ts create mode 100644 apps/web/src/terminalSessionController.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c8ce0121e..b4ca05886 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -22,6 +22,7 @@ import { Effect, Layer, Schema, Struct } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { createLogger } from "../../logger"; import { isPersistenceError, toPersistenceDecodeError, @@ -140,6 +141,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const logger = createLogger("projection-snapshot"); const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, @@ -326,6 +328,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql .withTransaction( Effect.gen(function* () { + const startedAt = performance.now(); const [ projectRows, threadRows, @@ -584,11 +587,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: updatedAt ?? new Date(0).toISOString(), }; - return yield* decodeReadModel(snapshot).pipe( + const decodedSnapshot = yield* decodeReadModel(snapshot).pipe( Effect.mapError( toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), ), ); + logger.info("built orchestration snapshot", { + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + projectCount: decodedSnapshot.projects.length, + threadCount: decodedSnapshot.threads.length, + messageCount: messageRows.length, + activityCount: activityRows.length, + checkpointCount: checkpointRows.length, + }); + return decodedSnapshot; }), ) .pipe( diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 5ff19423a..f78474fc6 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -28,6 +28,7 @@ import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { EnvironmentVariablesLive } from "./persistence/Services/EnvironmentVariables"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; +import { TerminalRuntimeEnvResolverLive } from "./terminal/Layers/RuntimeEnvResolver"; import { KeybindingsLive } from "./keybindings"; import { SkillServiceLive } from "./skills/SkillService"; import { GitManagerLive } from "./git/Layers/GitManager"; @@ -177,6 +178,7 @@ export function makeServerRuntimeServicesLayer() { prReviewLayer, githubLayer, terminalLayer, + TerminalRuntimeEnvResolverLive, KeybindingsLive, SkillServiceLive, smeChatLayer, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 86af2b9b2..e3f0cd2a1 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -8,7 +8,7 @@ import { type TerminalOpenInput, type TerminalRestartInput, } from "@okcode/contracts"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { PtySpawnError, @@ -381,6 +381,22 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("skips repeated missing-history reads for brand-new sidecar terminals", async () => { + const { manager, logsDir } = makeManager(); + const targetHistoryPath = multiTerminalHistoryLogPath(logsDir, "thread-1", "sidecar"); + const readFileSpy = vi.spyOn(fs.promises, "readFile"); + + await manager.open(openInput({ terminalId: "sidecar" })); + await manager.close({ threadId: "thread-1", terminalId: "sidecar" }); + await manager.open(openInput({ terminalId: "sidecar" })); + + expect( + readFileSpy.mock.calls.filter(([filePath]) => String(filePath) === targetHistoryPath), + ).toHaveLength(1); + + manager.dispose(); + }); + it("emits exited event and reopens with clean transcript after exit", async () => { const { manager, ptyAdapter, logsDir } = makeManager(); const events: TerminalEvent[] = []; diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 5b325d729..055facbaa 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -507,6 +507,7 @@ export class TerminalManagerRuntime extends EventEmitter private readonly persistQueues = new Map>(); private readonly persistTimers = new Map>(); private readonly pendingPersistHistory = new Map(); + private readonly historyKnownMissing = new Set(); private readonly threadLocks = new Map>(); private readonly persistDebounceMs: number; private readonly subprocessChecker: TerminalSubprocessChecker; @@ -759,6 +760,7 @@ export class TerminalManagerRuntime extends EventEmitter } this.killEscalationTimers.clear(); this.pendingPersistHistory.clear(); + this.historyKnownMissing.clear(); this.threadLocks.clear(); this.persistQueues.clear(); } @@ -781,6 +783,7 @@ export class TerminalManagerRuntime extends EventEmitter let ptyProcess: PtyProcess | null = null; let startedShell: string | null = null; + const spawnStartedAt = performance.now(); try { const shellCandidates = resolveShellCandidates(this.shellResolver); const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); @@ -856,6 +859,13 @@ export class TerminalManagerRuntime extends EventEmitter createdAt: new Date().toISOString(), snapshot: this.snapshot(session), }); + this.logger.info("terminal session started", { + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + durationMs: Math.round((performance.now() - spawnStartedAt) * 100) / 100, + ...(startedShell ? { shell: startedShell } : {}), + }); } catch (error) { if (ptyProcess) { this.killProcessWithEscalation(ptyProcess, session.threadId, session.terminalId); @@ -878,6 +888,7 @@ export class TerminalManagerRuntime extends EventEmitter this.logger.error("failed to start terminal", { threadId: session.threadId, terminalId: session.terminalId, + durationMs: Math.round((performance.now() - spawnStartedAt) * 100) / 100, error: message, ...(startedShell ? { shell: startedShell } : {}), }); @@ -1014,6 +1025,7 @@ export class TerminalManagerRuntime extends EventEmitter this.sessions.delete(key); this.clearPersistTimer(session.threadId, session.terminalId); this.pendingPersistHistory.delete(key); + this.historyKnownMissing.delete(key); this.persistQueues.delete(key); this.clearKillEscalationTimer(session.process); } @@ -1044,6 +1056,7 @@ export class TerminalManagerRuntime extends EventEmitter const persistenceKey = toSessionKey(threadId, terminalId); const task = async () => { await fs.promises.writeFile(this.historyPath(threadId, terminalId), history, "utf8"); + this.historyKnownMissing.delete(persistenceKey); }; const previous = this.persistQueues.get(persistenceKey) ?? Promise.resolve(); const next = previous @@ -1094,13 +1107,34 @@ export class TerminalManagerRuntime extends EventEmitter } private async readHistory(threadId: string, terminalId: string): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); + if (this.historyKnownMissing.has(persistenceKey)) { + this.logger.info("restored terminal history", { + threadId, + terminalId, + source: "missing-cache", + durationMs: 0, + bytes: 0, + }); + return ""; + } + const nextPath = this.historyPath(threadId, terminalId); + const startedAt = performance.now(); try { const raw = await fs.promises.readFile(nextPath, "utf8"); const capped = capHistory(raw, this.historyLineLimit); if (capped !== raw) { await fs.promises.writeFile(nextPath, capped, "utf8"); } + this.historyKnownMissing.delete(persistenceKey); + this.logger.info("restored terminal history", { + threadId, + terminalId, + source: "current", + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + bytes: capped.length, + }); return capped; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { @@ -1109,6 +1143,14 @@ export class TerminalManagerRuntime extends EventEmitter } if (terminalId !== DEFAULT_TERMINAL_ID) { + this.historyKnownMissing.add(persistenceKey); + this.logger.info("restored terminal history", { + threadId, + terminalId, + source: "missing", + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + bytes: 0, + }); return ""; } @@ -1128,9 +1170,25 @@ export class TerminalManagerRuntime extends EventEmitter }); } + this.historyKnownMissing.delete(persistenceKey); + this.logger.info("restored terminal history", { + threadId, + terminalId, + source: "legacy", + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + bytes: capped.length, + }); return capped; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.historyKnownMissing.add(persistenceKey); + this.logger.info("restored terminal history", { + threadId, + terminalId, + source: "missing", + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + bytes: 0, + }); return ""; } throw error; @@ -1138,12 +1196,14 @@ export class TerminalManagerRuntime extends EventEmitter } private async deleteHistory(threadId: string, terminalId: string): Promise { + const persistenceKey = toSessionKey(threadId, terminalId); const deletions = [fs.promises.rm(this.historyPath(threadId, terminalId), { force: true })]; if (terminalId === DEFAULT_TERMINAL_ID) { deletions.push(fs.promises.rm(this.legacyHistoryPath(threadId), { force: true })); } try { await Promise.all(deletions); + this.historyKnownMissing.add(persistenceKey); } catch (error) { this.logger.warn("failed to delete terminal history", { threadId, diff --git a/apps/server/src/terminal/Layers/RuntimeEnvResolver.ts b/apps/server/src/terminal/Layers/RuntimeEnvResolver.ts new file mode 100644 index 000000000..77158833a --- /dev/null +++ b/apps/server/src/terminal/Layers/RuntimeEnvResolver.ts @@ -0,0 +1,16 @@ +import { Layer } from "effect"; + +import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; +import { EnvironmentVariablesLive } from "../../persistence/Services/EnvironmentVariables.ts"; +import { + makeTerminalRuntimeEnvResolver, + TerminalRuntimeEnvResolver, +} from "../Services/RuntimeEnvResolver.ts"; + +export const TerminalRuntimeEnvResolverLive = Layer.effect( + TerminalRuntimeEnvResolver, + makeTerminalRuntimeEnvResolver, +).pipe( + Layer.provideMerge(ProjectionThreadRepositoryLive), + Layer.provideMerge(EnvironmentVariablesLive), +); diff --git a/apps/server/src/terminal/Services/RuntimeEnvResolver.test.ts b/apps/server/src/terminal/Services/RuntimeEnvResolver.test.ts new file mode 100644 index 000000000..c451b3cbf --- /dev/null +++ b/apps/server/src/terminal/Services/RuntimeEnvResolver.test.ts @@ -0,0 +1,111 @@ +import { Effect, Option } from "effect"; +import { describe, expect, it } from "vitest"; + +import { + EnvironmentVariables, + type EnvironmentVariablesShape, +} from "../../persistence/Services/EnvironmentVariables.ts"; +import { + type ProjectionThread, + ProjectionThreadRepository, + type ProjectionThreadRepositoryShape, +} from "../../persistence/Services/ProjectionThreads.ts"; +import { makeTerminalRuntimeEnvResolver } from "./RuntimeEnvResolver.ts"; + +const baseThread: ProjectionThread = { + threadId: "thread-1" as never, + projectId: "project-1" as never, + title: "Thread", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: "chat", + branch: null, + worktreePath: null, + githubRef: null, + latestTurnId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}; + +describe("TerminalRuntimeEnvResolver", () => { + it("resolves project-scoped env for a live thread and lets extra env win", async () => { + const threadRepository: ProjectionThreadRepositoryShape = { + upsert: () => Effect.void, + getById: () => Effect.succeed(Option.some(baseThread)), + listByProjectId: () => Effect.succeed([]), + deleteById: () => Effect.void, + }; + const environmentVariables: EnvironmentVariablesShape = { + getGlobal: () => Effect.succeed({ entries: [] }), + saveGlobal: () => Effect.succeed({ entries: [] }), + getProject: () => Effect.succeed({ projectId: "project-1" as never, entries: [] }), + saveProject: () => Effect.succeed({ projectId: "project-1" as never, entries: [] }), + resolveEnvironment: (input) => + Effect.succeed( + input?.projectId + ? { SHARED: "project", PROJECT_ONLY: input.projectId } + : { SHARED: "global", GLOBAL_ONLY: "1" }, + ), + }; + + const resolver = await Effect.runPromise( + makeTerminalRuntimeEnvResolver.pipe( + Effect.provideService(ProjectionThreadRepository, threadRepository), + Effect.provideService(EnvironmentVariables, environmentVariables), + ), + ); + const resolved = await Effect.runPromise( + resolver.resolve({ + threadId: "thread-1" as never, + cwd: "/repo", + extraEnv: { SHARED: "extra", EXTRA_ONLY: "1" }, + }), + ); + + expect(resolved).toEqual({ + SHARED: "extra", + PROJECT_ONLY: "project-1", + EXTRA_ONLY: "1", + }); + }); + + it("falls back to global env when the thread is missing or deleted", async () => { + const threadRepository: ProjectionThreadRepositoryShape = { + upsert: () => Effect.void, + getById: () => + Effect.succeed( + Option.some({ + ...baseThread, + deletedAt: "2026-01-02T00:00:00.000Z", + }), + ), + listByProjectId: () => Effect.succeed([]), + deleteById: () => Effect.void, + }; + const environmentVariables: EnvironmentVariablesShape = { + getGlobal: () => Effect.succeed({ entries: [] }), + saveGlobal: () => Effect.succeed({ entries: [] }), + getProject: () => Effect.succeed({ projectId: "project-1" as never, entries: [] }), + saveProject: () => Effect.succeed({ projectId: "project-1" as never, entries: [] }), + resolveEnvironment: (input) => + Effect.succeed(input?.projectId ? { PROJECT_ONLY: "1" } : { GLOBAL_ONLY: "1" }), + }; + + const resolver = await Effect.runPromise( + makeTerminalRuntimeEnvResolver.pipe( + Effect.provideService(ProjectionThreadRepository, threadRepository), + Effect.provideService(EnvironmentVariables, environmentVariables), + ), + ); + const resolved = await Effect.runPromise( + resolver.resolve({ + threadId: "thread-1" as never, + }), + ); + + expect(resolved).toEqual({ + GLOBAL_ONLY: "1", + }); + }); +}); diff --git a/apps/server/src/terminal/Services/RuntimeEnvResolver.ts b/apps/server/src/terminal/Services/RuntimeEnvResolver.ts new file mode 100644 index 000000000..e6456912b --- /dev/null +++ b/apps/server/src/terminal/Services/RuntimeEnvResolver.ts @@ -0,0 +1,54 @@ +import { type ThreadId } from "@okcode/contracts"; +import { type EnvironmentRecord, mergeEnvironmentRecords } from "@okcode/shared/environment"; +import { Effect, Option, ServiceMap } from "effect"; + +import { type ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import { + EnvironmentVariables, + type EnvironmentVariablesError, +} from "../../persistence/Services/EnvironmentVariables.ts"; +import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; + +export interface TerminalRuntimeEnvResolverInput { + readonly threadId: ThreadId; + readonly cwd?: string | null; + readonly extraEnv?: EnvironmentRecord; +} + +export type TerminalRuntimeEnvResolverError = ProjectionRepositoryError | EnvironmentVariablesError; + +export interface TerminalRuntimeEnvResolverShape { + readonly resolve: ( + input: TerminalRuntimeEnvResolverInput, + ) => Effect.Effect, TerminalRuntimeEnvResolverError>; +} + +export class TerminalRuntimeEnvResolver extends ServiceMap.Service< + TerminalRuntimeEnvResolver, + TerminalRuntimeEnvResolverShape +>()("okcode/terminal/Services/RuntimeEnvResolver") {} + +export const makeTerminalRuntimeEnvResolver = Effect.gen(function* () { + const threadRepository = yield* ProjectionThreadRepository; + const environmentVariables = yield* EnvironmentVariables; + + const resolve: TerminalRuntimeEnvResolverShape["resolve"] = (input) => + Effect.gen(function* () { + const threadOption = yield* threadRepository.getById({ threadId: input.threadId }); + const liveThread = + Option.isSome(threadOption) && threadOption.value.deletedAt === null + ? threadOption.value + : null; + const baseEnv = + liveThread === null + ? yield* environmentVariables.resolveEnvironment() + : yield* environmentVariables.resolveEnvironment({ + projectId: liveThread.projectId, + }); + return mergeEnvironmentRecords(baseEnv, input.extraEnv); + }); + + return { + resolve, + } satisfies TerminalRuntimeEnvResolverShape; +}); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 9ca1b468c..8e99a65aa 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -55,6 +55,10 @@ import { GitCore } from "./git/Services/GitCore.ts"; import { GitActionExecutionError, GitCommandError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { serverBuildInfo } from "./buildInfo"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "./orchestration/Services/ProjectionSnapshotQuery"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -508,6 +512,7 @@ describe("WebSocket Server", () => { gitManager?: GitManagerShape; gitCore?: Pick; terminalManager?: TerminalManagerShape; + projectionSnapshotQuery?: ProjectionSnapshotQueryShape; } = {}, ): Promise { if (serverScope) { @@ -542,6 +547,9 @@ describe("WebSocket Server", () => { logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), } satisfies ServerConfigShape); const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); + const projectionSnapshotQueryLayer = options.projectionSnapshotQuery + ? Layer.succeed(ProjectionSnapshotQuery, options.projectionSnapshotQuery) + : OrchestrationProjectionSnapshotQueryLive; const runtimeOverrides = Layer.mergeAll( options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, options.gitCore @@ -550,6 +558,7 @@ describe("WebSocket Server", () => { options.terminalManager ? Layer.succeed(TerminalManager, options.terminalManager) : Layer.empty, + projectionSnapshotQueryLayer, ); const runtimeLayer = Layer.merge( @@ -570,7 +579,7 @@ describe("WebSocket Server", () => { Layer.build( dependenciesLayer.pipe( Layer.provideMerge(EnvironmentVariablesLive), - Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), + Layer.provideMerge(projectionSnapshotQueryLayer), Layer.provideMerge(serverConfigLayer), Layer.provideMerge(persistenceLayer), ), @@ -1528,6 +1537,47 @@ describe("WebSocket Server", () => { expect(push.channel).toBe(WS_CHANNELS.terminalEvent); }); + it("opens and restarts terminals without touching the full snapshot query", async () => { + const cwd = makeTempDir("okcode-ws-terminal-fast-path-"); + const terminalManager = new MockTerminalManager(); + const { cwd: workspaceCwd } = makeWorkspaceFixture("terminal-fast-path"); + const projectionSnapshotQuery: ProjectionSnapshotQueryShape = { + getSnapshot: () => + Effect.die(new Error("terminal fast path should not call getSnapshot on open/restart")), + }; + server = await createTestServer({ + cwd: workspaceCwd, + terminalManager, + projectionSnapshotQuery, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const open = await sendRequest(ws, WS_METHODS.terminalOpen, { + threadId: "thread-fast-path", + cwd, + cols: 100, + rows: 24, + env: { TEST_FAST_PATH: "open" }, + }); + expect(open.error).toBeUndefined(); + expect((open.result as TerminalSessionSnapshot).status).toBe("running"); + + const restart = await sendRequest(ws, WS_METHODS.terminalRestart, { + threadId: "thread-fast-path", + cwd, + terminalId: DEFAULT_TERMINAL_ID, + cols: 120, + rows: 30, + env: { TEST_FAST_PATH: "restart" }, + }); + expect(restart.error).toBeUndefined(); + expect((restart.result as TerminalSessionSnapshot).status).toBe("running"); + }); + it("detaches terminal event listener on stop for injected manager", async () => { const terminalManager = new MockTerminalManager(); const { cwd } = makeWorkspaceFixture("test"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 4cae45579..47474d557 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -98,6 +98,7 @@ import { SkillService } from "./skills/SkillService.ts"; import { SmeChatService } from "./sme/Services/SmeChatService.ts"; import { TokenManager } from "./tokenManager.ts"; import { resolveRuntimeEnvironment, RuntimeEnv } from "./runtimeEnvironment.ts"; +import { TerminalRuntimeEnvResolver } from "./terminal/Services/RuntimeEnvResolver.ts"; import { version as serverVersion } from "../package.json" with { type: "json" }; import { serverBuildInfo } from "./buildInfo"; import { runOpenclawGatewayTest } from "./openclawGatewayTest.ts"; @@ -310,6 +311,7 @@ export type ServerRuntimeServices = | PrReview | GitHub | TerminalManager + | TerminalRuntimeEnvResolver | Keybindings | SkillService | SmeChatService @@ -765,6 +767,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const orchestrationReactor = yield* OrchestrationReactor; const prReview = yield* PrReview; const github = yield* GitHub; + const terminalRuntimeEnvResolver = yield* TerminalRuntimeEnvResolver; const { openInEditor, openInFileManager, revealInFileManager } = yield* Open; const environmentVariables = yield* EnvironmentVariables; const skillService = yield* SkillService; @@ -1403,20 +1406,24 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.terminalOpen: { const body = stripRequestTag(request.body); - const snapshot = yield* projectionReadModelQuery.getSnapshot(); - const runtimeEnv = yield* resolveRuntimeEnvironment({ - projectId: - snapshot.threads.find( - (thread) => thread.id === body.threadId && thread.deletedAt === null, - )?.projectId ?? null, + const envResolutionStartedAt = performance.now(); + const runtimeEnv = yield* terminalRuntimeEnvResolver.resolve({ + threadId: ThreadId.makeUnsafe(body.threadId), cwd: body.cwd, - readModel: snapshot, ...(body.env !== undefined ? { extraEnv: body.env } : {}), }); - return yield* terminalManager.open({ + const envResolutionMs = + Math.round((performance.now() - envResolutionStartedAt) * 100) / 100; + const session = yield* terminalManager.open({ ...body, env: runtimeEnv, }); + logger.info("terminal open prepared", { + threadId: body.threadId, + terminalId: body.terminalId ?? "default", + envResolutionMs, + }); + return session; } case WS_METHODS.terminalWrite: { @@ -1436,20 +1443,24 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.terminalRestart: { const body = stripRequestTag(request.body); - const snapshot = yield* projectionReadModelQuery.getSnapshot(); - const runtimeEnv = yield* resolveRuntimeEnvironment({ - projectId: - snapshot.threads.find( - (thread) => thread.id === body.threadId && thread.deletedAt === null, - )?.projectId ?? null, + const envResolutionStartedAt = performance.now(); + const runtimeEnv = yield* terminalRuntimeEnvResolver.resolve({ + threadId: ThreadId.makeUnsafe(body.threadId), cwd: body.cwd, - readModel: snapshot, ...(body.env !== undefined ? { extraEnv: body.env } : {}), }); - return yield* terminalManager.restart({ + const envResolutionMs = + Math.round((performance.now() - envResolutionStartedAt) * 100) / 100; + const session = yield* terminalManager.restart({ ...body, env: runtimeEnv, }); + logger.info("terminal restart prepared", { + threadId: body.threadId, + terminalId: body.terminalId ?? "default", + envResolutionMs, + }); + return session; } case WS_METHODS.terminalClose: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55585a816..ab66e6d63 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -183,6 +183,7 @@ import { import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { clearTerminalLaunchState, ensureTerminalOpen } from "../terminalSessionController"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; @@ -617,6 +618,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { const dragDepthRef = useRef(0); const fileInputRef = useRef(null); const terminalOpenByThreadRef = useRef>({}); + const hasPrefetchedTerminalDrawerRef = useRef(false); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); @@ -731,6 +733,23 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { const previewPanelKey = activeProject ? `${activeProject.id}:${previewDock}:${previewLayoutMode}` : null; + const activeThreadStableId = activeThread?.id ?? null; + const [mountedTerminalThreadId, setMountedTerminalThreadId] = useState(null); + const shouldMountTerminalDrawer = + activeThreadStableId !== null && + (terminalState.terminalOpen || mountedTerminalThreadId === activeThreadStableId); + + useEffect(() => { + if (!activeThreadStableId) { + setMountedTerminalThreadId(null); + return; + } + if (terminalState.terminalOpen) { + setMountedTerminalThreadId(activeThreadStableId); + return; + } + setMountedTerminalThreadId((current) => (current === activeThreadStableId ? current : null)); + }, [activeThreadStableId, terminalState.terminalOpen]); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1494,6 +1513,27 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { () => new Set(nonPersistedComposerAttachmentIds), [nonPersistedComposerAttachmentIds], ); + useEffect(() => { + if (typeof window === "undefined") { + return undefined; + } + + if (typeof window.requestIdleCallback === "function") { + const idleHandle = window.requestIdleCallback(() => { + void preloadThreadTerminalDrawer(); + }); + return () => { + window.cancelIdleCallback?.(idleHandle); + }; + } + + const timer = window.setTimeout(() => { + void preloadThreadTerminalDrawer(); + }, 150); + return () => { + window.clearTimeout(timer); + }; + }, []); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProviderStatus = useMemo( @@ -1669,22 +1709,85 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { }, [activeThreadId, storeSetTerminalHeight], ); + const ensureThreadTerminalOpen = useCallback( + async (options: { + terminalId: string; + cwd?: string; + cols?: number; + rows?: number; + env?: Record; + }) => { + if (!activeThreadId) return; + const targetCwd = options.cwd ?? gitCwd ?? activeProjectCwd; + if (!targetCwd) return; + await ensureTerminalOpen({ + threadId: activeThreadId, + terminalId: options.terminalId, + cwd: targetCwd, + ...(options.cols !== undefined ? { cols: options.cols } : {}), + ...(options.rows !== undefined ? { rows: options.rows } : {}), + ...(options.env !== undefined ? { env: options.env } : {}), + }); + }, + [activeProjectCwd, activeThreadId, gitCwd], + ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; - setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + const nextOpen = !terminalState.terminalOpen; + setTerminalOpen(nextOpen); + if (!nextOpen) { + return; + } + const targetTerminalId = + terminalState.activeTerminalId || terminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; + void ensureThreadTerminalOpen({ + terminalId: targetTerminalId, + env: threadTerminalRuntimeEnv, + }); + }, [ + activeThreadId, + ensureThreadTerminalOpen, + setTerminalOpen, + terminalState.activeTerminalId, + terminalState.terminalIds, + terminalState.terminalOpen, + threadTerminalRuntimeEnv, + ]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; + setTerminalOpen(true); storeSplitTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); + void ensureThreadTerminalOpen({ + terminalId, + env: threadTerminalRuntimeEnv, + }); + }, [ + activeThreadId, + ensureThreadTerminalOpen, + hasReachedSplitLimit, + setTerminalOpen, + storeSplitTerminal, + threadTerminalRuntimeEnv, + ]); const createNewTerminal = useCallback(() => { if (!activeThreadId) return; const terminalId = `terminal-${randomUUID()}`; + setTerminalOpen(true); storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal]); + void ensureThreadTerminalOpen({ + terminalId, + env: threadTerminalRuntimeEnv, + }); + }, [ + activeThreadId, + ensureThreadTerminalOpen, + setTerminalOpen, + storeNewTerminal, + threadTerminalRuntimeEnv, + ]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; @@ -1719,6 +1822,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { void fallbackExitWrite(); } storeCloseTerminal(activeThreadId, terminalId); + clearTerminalLaunchState(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], @@ -1777,7 +1881,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { }; try { - await api.terminal.open(openTerminalInput); + await ensureTerminalOpen(openTerminalInput); await api.terminal.write({ threadId: activeThreadId, terminalId: targetTerminalId, @@ -2778,6 +2882,10 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { }, [activeThreadId, focusComposer, terminalState.terminalOpen]); useEffect(() => { + if (!hasPrefetchedTerminalDrawerRef.current) { + hasPrefetchedTerminalDrawerRef.current = true; + void preloadThreadTerminalDrawer(); + } const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { @@ -5695,7 +5803,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { {/* Terminal drawer – once mounted, stay mounted to avoid the unmount/remount flicker when toggling visibility or switching threads. We hide it with display:none when collapsed so the DOM is retained. */} - {activeProject && ( + {activeProject && shouldMountTerminalDrawer ? (
} @@ -5726,7 +5834,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) { />
- )} + ) : null} { expect(addSelections).toEqual(["bun lint"]); }); + + it("describes opening and error launch states for the terminal overlay", () => { + expect( + resolveTerminalLaunchOverlay({ + status: "opening", + errorMessage: null, + lastRequestKey: "request-1", + }), + ).toEqual({ + title: "Starting terminal...", + description: "Connecting the shell and restoring saved output.", + canRetry: false, + }); + + expect( + resolveTerminalLaunchOverlay({ + status: "error", + errorMessage: "spawn failed", + lastRequestKey: "request-2", + }), + ).toEqual({ + title: "Terminal failed to start", + description: "spawn failed", + canRetry: true, + }); + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0958988ad..5fc9408b9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -41,6 +41,11 @@ import { } from "../types"; import { readNativeApi } from "~/nativeApi"; import { getStoredFontSizeOverride } from "~/lib/customTheme"; +import { + ensureTerminalOpen, + type TerminalLaunchState, + useTerminalLaunchState, +} from "../terminalSessionController"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -61,6 +66,17 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n\x1b[33m⚠ ${message}\x1b[0m\r\n`); } +function markTerminalViewportPerformance(step: string, threadId: string, terminalId: string): void { + if (typeof window === "undefined" || typeof performance === "undefined") { + return; + } + try { + performance.mark(`okcode:terminal:${step}:${threadId}:${terminalId}`); + } catch { + // Best-effort instrumentation only. + } +} + function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); @@ -205,6 +221,26 @@ export function dispatchTerminalShortcutSelection( handler(selection); } +export function resolveTerminalLaunchOverlay( + launchState: TerminalLaunchState, +): { title: string; description: string | null; canRetry: boolean } | null { + if (launchState.status === "opening") { + return { + title: "Starting terminal...", + description: "Connecting the shell and restoring saved output.", + canRetry: false, + }; + } + if (launchState.status === "error") { + return { + title: "Terminal failed to start", + description: launchState.errorMessage, + canRetry: true, + }; + } + return null; +} + interface TerminalViewportProps { threadId: ThreadId; terminalId: string; @@ -236,6 +272,7 @@ function TerminalViewport({ resizeEpoch, drawerHeight, }: TerminalViewportProps) { + const launchState = useTerminalLaunchState(threadId, terminalId); const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); @@ -295,8 +332,10 @@ function TerminalViewport({ theme: terminalThemeFromApp(), }); terminal.loadAddon(fitAddon); + markTerminalViewportPerformance("drawer-visible", threadId, terminalId); terminal.open(mount); fitAddon.fit(); + markTerminalViewportPerformance("xterm-mounted", threadId, terminalId); terminalRef.current = terminal; fitAddonRef.current = fitAddon; @@ -566,7 +605,7 @@ function TerminalViewport({ const activeFitAddon = fitAddonRef.current; if (!activeTerminal || !activeFitAddon) return; activeFitAddon.fit(); - const snapshot = await api.terminal.open({ + await ensureTerminalOpen({ threadId, terminalId, cwd, @@ -575,19 +614,13 @@ function TerminalViewport({ ...(runtimeEnv ? { env: runtimeEnv } : {}), }); if (disposed) return; - activeTerminal.write("\u001bc"); - scheduleHistoryReplay(snapshot.history); if (autoFocus) { window.requestAnimationFrame(() => { activeTerminal.focus(); }); } - } catch (err) { + } catch { if (disposed) return; - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Failed to open terminal", - ); } }; @@ -781,6 +814,18 @@ function TerminalViewport({ hoverBufferLineRef.current = null; setHoverLine(null); }, []); + const launchOverlay = resolveTerminalLaunchOverlay(launchState); + const retryTerminalOpen = useCallback(() => { + const terminal = terminalRef.current; + void ensureTerminalOpen({ + threadId, + terminalId, + cwd, + cols: terminal?.cols, + rows: terminal?.rows, + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }).catch(() => undefined); + }, [cwd, runtimeEnv, terminalId, threadId]); return (
+ {launchOverlay ? ( +
+
+

{launchOverlay.title}

+ {launchOverlay.description ? ( +

+ {launchOverlay.description} +

+ ) : null} + {launchOverlay.canRetry ? ( + + ) : null} +
+
+ ) : null} {hoverLine !== null && (