From c9f258407858d75c903b502f2838055e64ea5257 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 3 Apr 2026 19:49:01 -0700 Subject: [PATCH 1/2] Move worktree bootstrap to the server and persist terminal launch context (#1518) Co-authored-by: codex Co-authored-by: Cursor Agent --- .../Layers/CheckpointStore.test.ts | 4 +- apps/server/src/git/Layers/GitManager.test.ts | 130 +++++- apps/server/src/git/Layers/GitManager.ts | 21 + .../Layers/ProjectSetupScriptRunner.test.ts | 166 +++++++ .../Layers/ProjectSetupScriptRunner.ts | 75 ++++ .../Services/ProjectSetupScriptRunner.ts | 37 ++ apps/server/src/server.test.ts | 423 ++++++++++++++++++ apps/server/src/server.ts | 13 +- .../src/terminal/Layers/Manager.test.ts | 79 ++++ apps/server/src/terminal/Layers/Manager.ts | 12 + apps/server/src/terminal/Services/Manager.ts | 1 + apps/server/src/ws.ts | 270 ++++++++++- apps/web/src/components/ChatView.browser.tsx | 267 ++++++++++- apps/web/src/components/ChatView.tsx | 308 ++++++------- .../components/PullRequestThreadDialog.tsx | 6 +- .../src/components/ThreadTerminalDrawer.tsx | 7 + apps/web/src/lib/gitReactQuery.ts | 17 +- apps/web/src/projectScripts.test.ts | 8 +- apps/web/src/projectScripts.ts | 36 -- apps/web/src/routes/__root.tsx | 18 +- apps/web/src/terminalActivity.test.ts | 1 + apps/web/src/terminalStateStore.test.ts | 97 ++++ apps/web/src/terminalStateStore.ts | 188 +++++++- packages/contracts/src/git.ts | 3 +- packages/contracts/src/orchestration.test.ts | 41 ++ packages/contracts/src/orchestration.ts | 27 ++ packages/contracts/src/terminal.test.ts | 26 ++ packages/contracts/src/terminal.ts | 3 + packages/shared/package.json | 4 + packages/shared/src/projectScripts.ts | 37 ++ 30 files changed, 2058 insertions(+), 267 deletions(-) create mode 100644 apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts create mode 100644 apps/server/src/project/Layers/ProjectSetupScriptRunner.ts create mode 100644 apps/server/src/project/Services/ProjectSetupScriptRunner.ts create mode 100644 packages/shared/src/projectScripts.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 69f885cec6..6e7b18277c 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -80,7 +80,7 @@ function initRepoWithCommit( }); } -function buildLargeText(lineCount = 6_000): string { +function buildLargeText(lineCount = 5_000): string { return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) .join("\n") .concat("\n"); @@ -115,7 +115,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { expect(diff).toContain("diff --git"); expect(diff).not.toContain("[truncated]"); - expect(diff).toContain("+line 05999"); + expect(diff).toContain("+line 04999"); }), ); }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 8fcac582ff..005bdb5bc6 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -6,7 +6,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; -import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts"; +import type { + GitActionProgressEvent, + GitPreparePullRequestThreadInput, + ModelSelection, + ThreadId, +} from "@t3tools/contracts"; import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; import { type GitManagerShape } from "../Services/GitManager.ts"; @@ -21,6 +26,11 @@ import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, + type ProjectSetupScriptRunnerShape, +} from "../../project/Services/ProjectSetupScriptRunner.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -593,7 +603,7 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe function preparePullRequestThread( manager: GitManagerShape, - input: { cwd: string; reference: string; mode: "local" | "worktree" }, + input: GitPreparePullRequestThreadInput, ) { return manager.preparePullRequestThread(input); } @@ -601,6 +611,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; + setupScriptRunner?: ProjectSetupScriptRunnerShape; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -618,6 +629,12 @@ function makeManager(input?: { const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), + Layer.succeed( + ProjectSetupScriptRunner, + input?.setupScriptRunner ?? { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }, + ), gitCoreLayer, serverSettingsLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -628,6 +645,8 @@ function makeManager(input?: { ); } +const asThreadId = (threadId: string) => threadId as ThreadId; + const GitManagerTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), Layer.provideMerge(NodeServices.layer), @@ -2176,6 +2195,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("launches setup only when creating a new PR worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); + fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); + yield* runGit(repoDir, ["add", "setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 177, + title: "Worktree setup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/177", + baseRefName: "main", + headRefName: "feature/pr-worktree-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "177", + mode: "worktree", + threadId: asThreadId("thread-pr-setup"), + }); + + expect(result.worktreePath).not.toBeNull(); + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + }), + ); + it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2360,6 +2432,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + const setupCalls: ProjectSetupScriptRunnerInput[] = []; const { manager } = yield* makeManager({ ghScenario: { pullRequest: { @@ -2371,18 +2444,27 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }, }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, }); const result = yield* preparePullRequestThread(manager, { cwd: repoDir, reference: "78", mode: "worktree", + threadId: asThreadId("thread-pr-existing-worktree"), }); expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( fs.realpathSync.native(worktreePath), ); expect(result.branch).toBe("feature/pr-existing-worktree"); + expect(setupCalls).toHaveLength(0); }), ); @@ -2562,6 +2644,50 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("does not fail PR worktree prep when setup terminal startup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); + fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); + yield* runGit(repoDir, ["add", "setup-failure.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 184, + title: "Setup failure PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/184", + baseRefName: "main", + headRefName: "feature/pr-setup-failure", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: () => Effect.fail(new Error("terminal start failed")), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "184", + mode: "worktree", + threadId: asThreadId("thread-pr-setup-failure"), + }); + + expect(result.branch).toBe("feature/pr-setup-failure"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + }), + ); + it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index abc4985abd..7fedb15714 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -26,6 +26,7 @@ import { import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; @@ -552,6 +553,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( @@ -1329,6 +1331,24 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { + const maybeRunSetupScript = (worktreePath: string) => { + if (!input.threadId) { + return Effect.void; + } + return projectSetupScriptRunner + .runForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + }; return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = canonicalizeExistingPath(input.cwd); @@ -1461,6 +1481,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { path: null, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + yield* maybeRunSetupScript(worktree.worktree.path); return { pullRequest, diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts new file mode 100644 index 0000000000..6366a768b7 --- /dev/null +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -0,0 +1,166 @@ +import { Effect, Layer, Stream } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import type { OrchestrationReadModel } from "@t3tools/contracts"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts"; +import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts"; + +const emptySnapshot = ( + scripts: OrchestrationReadModel["projects"][number]["scripts"], +): OrchestrationReadModel => + ({ + snapshotSequence: 1, + updatedAt: "2026-01-01T00:00:00.000Z", + projects: [ + { + id: "project-1", + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }, + ], + threads: [], + providerSessions: [], + providerStatuses: [], + pendingApprovals: [], + latestTurnByThreadId: {}, + }) as unknown as OrchestrationReadModel; + +describe("ProjectSetupScriptRunner", () => { + it("returns no-script when no setup script exists", async () => { + const open = vi.fn(); + const write = vi.fn(); + const runner = await Effect.runPromise( + Effect.service(ProjectSetupScriptRunner).pipe( + Effect.provide( + ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + getReadModel: () => Effect.succeed(emptySnapshot([])), + readEvents: () => Stream.empty, + dispatch: () => Effect.die(new Error("unused")), + streamDomainEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open, + write, + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die(new Error("unused")), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + }), + ), + ), + ), + ), + ); + + const result = await Effect.runPromise( + runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }), + ); + + expect(result).toEqual({ status: "no-script" }); + expect(open).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it("opens the deterministic setup terminal with worktree env and writes the command", async () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + const runner = await Effect.runPromise( + Effect.service(ProjectSetupScriptRunner).pipe( + Effect.provide( + ProjectSetupScriptRunnerLive.pipe( + Layer.provideMerge( + Layer.succeed(OrchestrationEngineService, { + getReadModel: () => + Effect.succeed( + emptySnapshot([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + ), + readEvents: () => Stream.empty, + dispatch: () => Effect.die(new Error("unused")), + streamDomainEvents: Stream.empty, + }), + ), + Layer.provideMerge( + Layer.succeed(TerminalManager, { + open, + write, + resize: () => Effect.void, + clear: () => Effect.void, + restart: () => Effect.die(new Error("unused")), + close: () => Effect.void, + subscribe: () => Effect.succeed(() => undefined), + }), + ), + ), + ), + ), + ); + + const result = await Effect.runPromise( + runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }), + ); + + expect(result).toEqual({ + status: "started", + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-setup", + data: "bun install\r", + }); + }); +}); diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts new file mode 100644 index 0000000000..3bac8cf0ab --- /dev/null +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -0,0 +1,75 @@ +import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { Effect, Layer } from "effect"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { TerminalManager } from "../../terminal/Services/Manager.ts"; +import { + type ProjectSetupScriptRunnerShape, + ProjectSetupScriptRunner, +} from "../Services/ProjectSetupScriptRunner.ts"; + +const makeProjectSetupScriptRunner = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const terminalManager = yield* TerminalManager; + + const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => + Effect.gen(function* () { + const readModel = yield* orchestrationEngine.getReadModel(); + const project = + (input.projectId + ? readModel.projects.find((entry) => entry.id === input.projectId) + : null) ?? + (input.projectCwd + ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd) + : null) ?? + null; + + if (!project) { + return yield* Effect.fail(new Error("Project was not found for setup script execution.")); + } + + const script = setupProjectScript(project.scripts); + if (!script) { + return { + status: "no-script", + } as const; + } + + const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + const cwd = input.worktreePath; + const env = projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: input.worktreePath, + }); + + yield* terminalManager.open({ + threadId: input.threadId, + terminalId, + cwd, + worktreePath: input.worktreePath, + env, + }); + yield* terminalManager.write({ + threadId: input.threadId, + terminalId, + data: `${script.command}\r`, + }); + + return { + status: "started", + scriptId: script.id, + scriptName: script.name, + terminalId, + cwd, + } as const; + }); + + return { + runForThread, + } satisfies ProjectSetupScriptRunnerShape; +}); + +export const ProjectSetupScriptRunnerLive = Layer.effect( + ProjectSetupScriptRunner, + makeProjectSetupScriptRunner, +); diff --git a/apps/server/src/project/Services/ProjectSetupScriptRunner.ts b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts new file mode 100644 index 0000000000..3828096f85 --- /dev/null +++ b/apps/server/src/project/Services/ProjectSetupScriptRunner.ts @@ -0,0 +1,37 @@ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ProjectSetupScriptRunnerResultNoScript { + readonly status: "no-script"; +} + +export interface ProjectSetupScriptRunnerResultStarted { + readonly status: "started"; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + readonly cwd: string; +} + +export type ProjectSetupScriptRunnerResult = + | ProjectSetupScriptRunnerResultNoScript + | ProjectSetupScriptRunnerResultStarted; + +export interface ProjectSetupScriptRunnerInput { + readonly threadId: string; + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + readonly preferredTerminalId?: string; +} + +export interface ProjectSetupScriptRunnerShape { + readonly runForThread: ( + input: ProjectSetupScriptRunnerInput, + ) => Effect.Effect; +} + +export class ProjectSetupScriptRunner extends ServiceMap.Service< + ProjectSetupScriptRunner, + ProjectSetupScriptRunnerShape +>()("t3/project/ProjectSetupScriptRunner") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 93990322ff..bf5ff32285 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -6,8 +6,10 @@ import { DEFAULT_SERVER_SETTINGS, GitCommandError, KeybindingRule, + MessageId, OpenError, TerminalNotRunningError, + type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, ProjectId, @@ -22,6 +24,7 @@ import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import { Effect, FileSystem, Layer, Path, Stream } from "effect"; import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { vi } from "vitest"; import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; @@ -39,6 +42,7 @@ import { OrchestrationEngineService, type OrchestrationEngineShape, } from "./orchestration/Services/OrchestrationEngine.ts"; +import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import { ProjectionSnapshotQuery, type ProjectionSnapshotQueryShape, @@ -53,6 +57,10 @@ import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRu import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerShape, +} from "./project/Services/ProjectSetupScriptRunner.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; @@ -125,6 +133,7 @@ const buildAppUnderTest = (options?: { open?: Partial; gitCore?: Partial; gitManager?: Partial; + projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; projectionSnapshotQuery?: Partial; @@ -209,6 +218,12 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitManager, }), ), + Layer.provide( + Layer.mock(ProjectSetupScriptRunner)({ + runForThread: () => Effect.succeed({ status: "no-script" as const }), + ...options?.layers?.projectSetupScriptRunner, + }), + ), Layer.provide( Layer.mock(TerminalManager)({ ...options?.layers?.terminalManager, @@ -1247,6 +1262,413 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "bootstraps first-send worktree turns on the server before dispatching turn start", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn((_: Parameters[0]) => + Effect.succeed({ + worktree: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + const runForThread = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitCore: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = new Date().toISOString(); + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start"), + threadId: ThreadId.makeUnsafe("thread-bootstrap"), + message: { + messageId: MessageId.makeUnsafe("msg-bootstrap"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-branch", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 5); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + [ + "thread.create", + "thread.meta.update", + "thread.activity.append", + "thread.activity.append", + "thread.turn.start", + ], + ); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + branch: "main", + newBranch: "t3code/bootstrap-branch", + path: null, + }); + assert.deepEqual(runForThread.mock.calls[0]?.[0], { + threadId: ThreadId.makeUnsafe("thread-bootstrap"), + projectId: defaultProjectId, + projectCwd: "/tmp/project", + worktreePath: "/tmp/bootstrap-worktree", + }); + + const setupActivities = dispatchedCommands.filter( + (command): command is Extract => + command.type === "thread.activity.append", + ); + assert.deepEqual( + setupActivities.map((command) => command.activity.kind), + ["setup-script.requested", "setup-script.started"], + ); + const finalCommand = dispatchedCommands[4]; + assertTrue(finalCommand?.type === "thread.turn.start"); + if (finalCommand?.type === "thread.turn.start") { + assert.equal(finalCommand.bootstrap, undefined); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("records setup-script failures without aborting bootstrap turn start", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn((_: Parameters[0]) => + Effect.succeed({ + worktree: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + const runForThread = vi.fn( + (_: Parameters[0]) => + Effect.fail(new Error("pty unavailable")), + ); + + yield* buildAppUnderTest({ + layers: { + gitCore: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = new Date().toISOString(); + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-setup-failure"), + threadId: ThreadId.makeUnsafe("thread-bootstrap-setup-failure"), + message: { + messageId: MessageId.makeUnsafe("msg-bootstrap-setup-failure"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-branch", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 4); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + ); + const setupFailureActivity = dispatchedCommands.find( + (command): command is Extract => + command.type === "thread.activity.append", + ); + assert.equal(setupFailureActivity?.activity.kind, "setup-script.failed"); + assert.deepEqual(setupFailureActivity?.activity.payload, { + detail: "pty unavailable", + worktreePath: "/tmp/bootstrap-worktree", + }); + assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn((_: Parameters[0]) => + Effect.succeed({ + worktree: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + const runForThread = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: "/tmp/bootstrap-worktree", + }), + ); + let setupActivityAppendAttempt = 0; + + yield* buildAppUnderTest({ + layers: { + gitCore: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => { + if ( + command.type === "thread.activity.append" && + command.activity.kind.startsWith("setup-script.") + ) { + setupActivityAppendAttempt += 1; + if (setupActivityAppendAttempt === 2) { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "failed to append setup-script.started activity", + }), + ); + } + } + + return Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }); + }, + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + }, + }); + + const createdAt = new Date().toISOString(); + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-setup-activity-failure"), + threadId: ThreadId.makeUnsafe("thread-bootstrap-setup-activity-failure"), + message: { + messageId: MessageId.makeUnsafe("msg-bootstrap-setup-activity-failure"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-branch", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 4); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + ); + const setupActivities = dispatchedCommands.filter( + (command): command is Extract => + command.type === "thread.activity.append", + ); + assert.deepEqual( + setupActivities.map((command) => command.activity.kind), + ["setup-script.requested"], + ); + assertTrue( + setupActivities.every((command) => command.activity.kind !== "setup-script.failed"), + ); + assertTrue(dispatchedCommands.every((command) => command.type !== "thread.delete")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("cleans up created bootstrap threads when worktree creation defects", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn((_: Parameters[0]) => + Effect.die(new Error("worktree exploded")), + ); + + yield* buildAppUnderTest({ + layers: { + gitCore: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = new Date().toISOString(); + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-bootstrap-turn-start-defect"), + threadId: ThreadId.makeUnsafe("thread-bootstrap-defect"), + message: { + messageId: MessageId.makeUnsafe("msg-bootstrap-defect"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-branch", + }, + runSetupScript: false, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "worktree exploded"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.delete"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeOrchestrationDomainEvents with replay/live overlap resilience", () => @@ -1342,6 +1764,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { threadId: "thread-1", terminalId: "default", cwd: "/tmp/project", + worktreePath: null, status: "running" as const, pid: 1234, history: "", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index bef9e09e03..314d7d774d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -47,6 +47,7 @@ import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResol import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; +import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; const PtyAdapterLive = Layer.unwrap( @@ -180,6 +181,7 @@ const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersisten const GitLayerLive = Layer.empty.pipe( Layer.provideMerge( GitManagerLive.pipe( + Layer.provideMerge(ProjectSetupScriptRunnerLive), Layer.provideMerge(GitCoreLive), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(RoutingTextGenerationLive), @@ -199,15 +201,12 @@ const WorkspaceLayerLive = Layer.mergeAll( ), ); -const RuntimeServicesLive = Layer.empty.pipe( - Layer.provideMerge(ServerRuntimeStartupLive), - Layer.provideMerge(ReactorLayerLive), - +const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), + Layer.provideMerge(GitLayerLive), Layer.provideMerge(OrchestrationLayerLive), Layer.provideMerge(ProviderLayerLive), - Layer.provideMerge(GitLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), @@ -222,6 +221,10 @@ const RuntimeServicesLive = Layer.empty.pipe( Layer.provideMerge(ServerLifecycleEventsLive), ); +const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( + Layer.provideMerge(RuntimeDependenciesLive), +); + export const makeRoutesLayer = Layer.mergeAll( attachmentsRouteLayer, projectFaviconRouteLayer, diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 878309afb4..4062f06e72 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -429,6 +429,85 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => + Effect.gen(function* () { + const { manager, getEvents, baseDir } = yield* createManager(); + const firstWorktreePath = path.join(baseDir, "worktrees", "feature-a"); + const secondWorktreePath = path.join(baseDir, "worktrees", "feature-b"); + yield* makeDirectory(firstWorktreePath); + yield* makeDirectory(secondWorktreePath); + const startedSnapshot = yield* manager.open( + openInput({ + cwd: firstWorktreePath, + worktreePath: firstWorktreePath, + }), + ); + const restartedSnapshot = yield* manager.restart( + restartInput({ + cwd: secondWorktreePath, + worktreePath: secondWorktreePath, + }), + ); + + assert.equal(startedSnapshot.worktreePath, firstWorktreePath); + assert.equal(restartedSnapshot.worktreePath, secondWorktreePath); + + const events = yield* getEvents; + const startedEvent = events.find( + (event): event is Extract => event.type === "started", + ); + const restartedEvent = events.find( + (event): event is Extract => + event.type === "restarted", + ); + + assert.equal(startedEvent?.snapshot.worktreePath, firstWorktreePath); + assert.equal(restartedEvent?.snapshot.worktreePath, secondWorktreePath); + }), + ); + + it.effect("preserves worktree metadata when reopening an exited session", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents, baseDir } = yield* createManager(); + const worktreePath = path.join(baseDir, "worktrees", "feature-a"); + yield* makeDirectory(worktreePath); + + yield* manager.open( + openInput({ + cwd: worktreePath, + worktreePath, + }), + ); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + ); + + const reopenedSnapshot = yield* manager.open( + openInput({ + cwd: worktreePath, + worktreePath, + }), + ); + + assert.equal(reopenedSnapshot.worktreePath, worktreePath); + + const events = yield* getEvents; + const reopenedEvent = events + .toReversed() + .find( + (event): event is Extract => event.type === "started", + ); + + assert.equal(reopenedEvent?.snapshot.worktreePath, worktreePath); + }), + ); + it.effect("emits exited event and reopens with clean transcript after exit", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index a6183825ff..f4cf0a6362 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -80,6 +80,7 @@ interface TerminalStartInput { threadId: string; terminalId: string; cwd: string; + worktreePath?: string | null; cols: number; rows: number; env?: Record; @@ -89,6 +90,7 @@ interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; + worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; history: string; @@ -146,6 +148,7 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { threadId: session.threadId, terminalId: session.terminalId, cwd: session.cwd, + worktreePath: session.worktreePath, status: session.status, pid: session.pid, history: session.history, @@ -1318,6 +1321,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith yield* modifyManagerState((state) => { session.status = "starting"; session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; session.cols = input.cols; session.rows = input.rows; session.exitCode = null; @@ -1589,6 +1593,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + worktreePath: input.worktreePath ?? null, status: "starting", pid: null, history, @@ -1623,6 +1628,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, rows, ...(input.env ? { env: input.env } : {}), @@ -1642,6 +1648,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (liveSession.cwd !== input.cwd || runtimeEnvChanged) { yield* stopProcess(liveSession); liveSession.cwd = input.cwd; + liveSession.worktreePath = input.worktreePath ?? null; liveSession.runtimeEnv = nextRuntimeEnv; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; @@ -1655,6 +1662,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith ); } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.worktreePath = input.worktreePath ?? null; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; @@ -1674,6 +1682,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + worktreePath: liveSession.worktreePath, cols: targetCols, rows: targetRows, ...(input.env ? { env: input.env } : {}), @@ -1765,6 +1774,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + worktreePath: input.worktreePath ?? null, status: "starting", pid: null, history: "", @@ -1795,6 +1805,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session = existingSession.value; yield* stopProcess(session); session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; session.runtimeEnv = normalizedRuntimeEnv(input.env); } @@ -1814,6 +1825,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), cols, rows, ...(input.env ? { env: input.env } : {}), diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index bdfbc85cc5..ebe83e362b 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -37,6 +37,7 @@ export interface TerminalSessionState { threadId: string; terminalId: string; cwd: string; + worktreePath: string | null; status: TerminalSessionStatus; pid: number | null; history: string; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index de263ecb92..33a0518611 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,5 +1,8 @@ -import { Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; import { + CommandId, + EventId, + type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, OrchestrationDispatchCommandError, @@ -11,6 +14,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, OrchestrationReplayEventsError, + ThreadId, type TerminalEvent, WS_METHODS, WsRpcGroup, @@ -41,6 +45,7 @@ import { TerminalManager } from "./terminal/Services/Manager"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; +import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -59,6 +64,265 @@ const WsRpcLayer = WsRpcGroup.toLayer( const startup = yield* ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + + const serverCommandId = (tag: string) => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + + const appendSetupScriptActivity = (input: { + readonly threadId: ThreadId; + readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly summary: string; + readonly createdAt: string; + readonly payload: Record; + readonly tone: "info" | "error"; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("setup-script-activity"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + + const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: cause instanceof Error ? cause.message : fallbackMessage, + cause, + }); + + const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return Schema.is(OrchestrationDispatchCommandError)(error) + ? error + : new OrchestrationDispatchCommandError({ + message: + error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", + cause, + }); + }; + + const dispatchBootstrapTurnStart = ( + command: Extract, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => + Effect.gen(function* () { + const bootstrap = command.bootstrap; + const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; + let createdThread = false; + let targetProjectId = bootstrap?.createThread?.projectId; + let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; + let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + + const cleanupCreatedThread = () => + createdThread + ? orchestrationEngine + .dispatch({ + type: "thread.delete", + commandId: serverCommandId("bootstrap-thread-delete"), + threadId: command.threadId, + }) + .pipe(Effect.ignoreCause({ log: true })) + : Effect.void; + + const recordSetupScriptLaunchFailure = (input: { + readonly error: unknown; + readonly requestedAt: string; + readonly worktreePath: string; + }) => { + const detail = + input.error instanceof Error ? input.error.message : "Unknown setup failure."; + return appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: input.requestedAt, + payload: { + detail, + worktreePath: input.worktreePath, + }, + tone: "error", + }).pipe( + Effect.ignoreCause({ log: false }), + Effect.flatMap(() => + Effect.logWarning("bootstrap turn start failed to launch setup script", { + threadId: command.threadId, + worktreePath: input.worktreePath, + detail, + }), + ), + ); + }; + + const recordSetupScriptStarted = (input: { + readonly requestedAt: string; + readonly worktreePath: string; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + }) => { + const payload = { + scriptId: input.scriptId, + scriptName: input.scriptName, + terminalId: input.terminalId, + worktreePath: input.worktreePath, + }; + return Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: input.requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: new Date().toISOString(), + payload, + tone: "info", + }), + ]).pipe( + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start launched setup script but failed to record setup activity", + { + threadId: command.threadId, + worktreePath: input.worktreePath, + scriptId: input.scriptId, + terminalId: input.terminalId, + detail: + error instanceof Error + ? error.message + : "Unknown setup activity dispatch failure.", + }, + ), + ), + ); + }; + + const runSetupProgram = () => + bootstrap?.runSetupScript && targetWorktreePath + ? (() => { + const worktreePath = targetWorktreePath; + const requestedAt = new Date().toISOString(); + return projectSetupScriptRunner + .runForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + recordSetupScriptLaunchFailure({ + error, + requestedAt, + worktreePath, + }), + onSuccess: (setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + return recordSetupScriptStarted({ + requestedAt, + worktreePath, + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + }); + }, + }), + ); + })() + : Effect.void; + + const bootstrapProgram = Effect.gen(function* () { + if (bootstrap?.createThread) { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }); + createdThread = true; + } + + if (bootstrap?.prepareWorktree) { + const worktree = yield* git.createWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + newBranch: bootstrap.prepareWorktree.branch, + path: null, + }); + targetWorktreePath = worktree.worktree.path; + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("bootstrap-thread-meta-update"), + threadId: command.threadId, + branch: worktree.worktree.branch, + worktreePath: targetWorktreePath, + }); + } + + yield* runSetupProgram(); + + return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + }); + + return yield* bootstrapProgram.pipe( + Effect.catchCause((cause) => { + const dispatchError = toBootstrapDispatchCommandCauseError(cause); + if (Cause.hasInterruptsOnly(cause)) { + return Effect.fail(dispatchError); + } + return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); + }), + ); + }); + + const dispatchNormalizedCommand = ( + normalizedCommand: OrchestrationCommand, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { + const dispatchEffect = + normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap + ? dispatchBootstrapTurnStart(normalizedCommand) + : orchestrationEngine + .dispatch(normalizedCommand) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + + return startup + .enqueueCommand(dispatchEffect) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + }; const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; @@ -104,9 +368,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { const normalizedCommand = yield* normalizeDispatchCommand(command); - const result = yield* startup.enqueueCommand( - orchestrationEngine.dispatch(normalizedCommand), - ); + const result = yield* dispatchNormalizedCommand(normalizedCommand); if (normalizedCommand.type === "thread.archive") { yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( Effect.catch((error) => diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 388def4fbe..fe917251a3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -33,6 +33,7 @@ import { isMacPlatform } from "../lib/utils"; import { __resetNativeApiForTests } from "../nativeApi"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { useTerminalStateStore } from "../terminalStateStore"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -682,6 +683,12 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { threadId: typeof body.threadId === "string" ? body.threadId : THREAD_ID, terminalId: typeof body.terminalId === "string" ? body.terminalId : "default", cwd: typeof body.cwd === "string" ? body.cwd : "/repo/project", + worktreePath: + typeof body.worktreePath === "string" + ? body.worktreePath + : body.worktreePath === null + ? null + : null, status: "running", pid: 123, history: "", @@ -1149,6 +1156,13 @@ describe("ChatView timeline estimator parity (full app)", () => { threads: [], bootstrapComplete: false, }); + useTerminalStateStore.persist.clearStorage(); + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, + terminalEventEntriesByKey: {}, + nextTerminalEventId: 1, + }); }); afterEach(() => { @@ -1372,6 +1386,74 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("does not leak a server worktree path into drawer runtime env when launch context clears it", async () => { + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-launch-context-target" as MessageId, + targetText: "launch context worktree override", + }); + const targetThread = snapshot.threads.find((thread) => thread.id === THREAD_ID); + if (targetThread) { + Object.assign(targetThread, { + branch: "feature/branch", + worktreePath: "/repo/worktrees/feature-branch", + }); + } + + useTerminalStateStore.setState({ + terminalStateByThreadId: { + [THREAD_ID]: { + terminalOpen: true, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + }, + terminalLaunchContextByThreadId: { + [THREAD_ID]: { + cwd: "/repo/project", + worktreePath: null, + }, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await vi.waitFor( + () => { + const openRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.terminalOpen, + ) as + | { + _tag: string; + cwd?: string; + worktreePath?: string | null; + env?: Record; + } + | undefined; + expect(openRequest).toMatchObject({ + _tag: WS_METHODS.terminalOpen, + cwd: "/repo/project", + worktreePath: null, + env: { + T3CODE_PROJECT_ROOT: "/repo/project", + }, + }); + expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("opens the project cwd with VS Code Insiders when it is the only available editor", async () => { setDraftThreadWithoutWorktree(); @@ -1714,7 +1796,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("runs setup scripts after preparing a pull request worktree thread", async () => { + it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ draftThreadsByThreadId: { [THREAD_ID]: { @@ -1819,45 +1901,188 @@ describe("ChatView timeline estimator parity (full app)", () => { cwd: "/repo/project", reference: "1359", mode: "worktree", + threadId: THREAD_ID, }); }, { timeout: 8_000, interval: 16 }, ); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", + ), + ).toBe(false); + } finally { + await mounted.cleanup(); + } + }); + + it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + }); + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + await vi.waitFor( () => { - const openRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalOpen && request.cwd === "/repo/worktrees/pr-1359", - ); - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, - threadId: expect.any(String), - cwd: "/repo/worktrees/pr-1359", - env: { - T3CODE_PROJECT_ROOT: "/repo/project", - T3CODE_WORKTREE_PATH: "/repo/worktrees/pr-1359", + const dispatchRequest = wsRequests.find( + (request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand, + ) as + | { + _tag: string; + type?: string; + bootstrap?: { + createThread?: { projectId?: string }; + prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; + runSetupScript?: boolean; + }; + } + | undefined; + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "thread.turn.start", + bootstrap: { + createThread: { + projectId: PROJECT_ID, + }, + prepareWorktree: { + projectCwd: "/repo/project", + baseBranch: "main", + branch: expect.stringMatching(/^t3code\/[0-9a-f]{8}$/), + }, + runSetupScript: true, }, }); }, { timeout: 8_000, interval: 16 }, ); + expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( + false, + ); + expect( + wsRequests.some( + (request) => + request._tag === WS_METHODS.terminalWrite && + request.threadId === THREAD_ID && + request.data === "bun install\r", + ), + ).toBe(false); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the send state once bootstrap dispatch is in flight", async () => { + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + }); + useComposerDraftStore.setState({ + draftThreadsByThreadId: { + [THREAD_ID]: { + projectId: PROJECT_ID, + createdAt: NOW_ISO, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "worktree", + }, + }, + projectDraftThreadIdByProjectId: { + [PROJECT_ID]: THREAD_ID, + }, + }); + + let resolveDispatch!: (value: { sequence: number }) => void; + const dispatchPromise = new Promise<{ sequence: number }>((resolve) => { + resolveDispatch = resolve; + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: withProjectScripts(createDraftOnlySnapshot(), [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return dispatchPromise; + } + return undefined; + }, + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + await waitForLayout(); + + const sendButton = await waitForSendButton(); + expect(sendButton.disabled).toBe(false); + sendButton.click(); + await vi.waitFor( () => { - const writeRequest = wsRequests.find( - (request) => - request._tag === WS_METHODS.terminalWrite && request.data === "bun install\r", - ); - expect(writeRequest).toMatchObject({ - _tag: WS_METHODS.terminalWrite, - threadId: expect.any(String), - data: "bun install\r", - }); + expect( + wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand), + ).toBe(true); + expect(document.querySelector('button[aria-label="Sending"]')).toBeTruthy(); + expect(document.querySelector('button[aria-label="Preparing worktree"]')).toBeNull(); }, { timeout: 8_000, interval: 16 }, ); } finally { + resolveDispatch({ sequence: fixture.snapshot.snapshotSequence + 1 }); await mounted.cleanup(); } }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 78d3e3cc71..3933fa35b4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,12 +21,13 @@ import { TerminalOpenInput, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -111,10 +112,7 @@ import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, - projectScriptCwd, - projectScriptRuntimeEnv, projectScriptIdFromCommand, - setupProjectScript, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; @@ -333,12 +331,14 @@ interface ChatViewProps { threadId: ThreadId; } -interface PendingPullRequestSetupRequest { +interface TerminalLaunchContext { threadId: ThreadId; - worktreePath: string; - scriptId: string; + cwd: string; + worktreePath: string | null; } +type PersistentTerminalLaunchContext = Pick; + function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestTurn: Thread["latestTurn"] | null; @@ -409,6 +409,7 @@ function useLocalDispatchState(input: { interface PersistentThreadTerminalDrawerProps { threadId: ThreadId; visible: boolean; + launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; newShortcutLabel: string | undefined; @@ -419,6 +420,7 @@ interface PersistentThreadTerminalDrawerProps { function PersistentThreadTerminalDrawer({ threadId, visible, + launchContext, focusRequestId, splitShortcutLabel, newShortcutLabel, @@ -440,28 +442,33 @@ function PersistentThreadTerminalDrawer({ const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread - ? serverThread.worktreePath - : (draftThread?.worktreePath ?? null); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const effectiveWorktreePath = useMemo(() => { + if (launchContext !== null) { + return launchContext.worktreePath; + } + return worktreePath; + }, [launchContext, worktreePath]); const cwd = useMemo( () => - project + launchContext?.cwd ?? + (project ? projectScriptCwd({ project: { cwd: project.cwd }, - worktreePath, + worktreePath: effectiveWorktreePath, }) - : null, - [project, worktreePath], + : null), + [effectiveWorktreePath, launchContext?.cwd, project], ); const runtimeEnv = useMemo( () => project ? projectScriptRuntimeEnv({ project: { cwd: project.cwd }, - worktreePath, + worktreePath: effectiveWorktreePath, }) : {}, - [project, worktreePath], + [effectiveWorktreePath, project], ); const bumpFocusRequestId = useCallback(() => { @@ -556,6 +563,7 @@ function PersistentThreadTerminalDrawer({ store.setError); - const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[threadId], @@ -597,8 +604,6 @@ export default function ChatView({ threadId }: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); - const queryClient = useQueryClient(); - const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; @@ -689,8 +694,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [pendingPullRequestSetupRequest, setPendingPullRequestSetupRequest] = - useState(null); + const [terminalLaunchContext, setTerminalLaunchContext] = useState( + null, + ); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -756,6 +762,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const storeServerTerminalLaunchContext = useTerminalStateStore( + (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, + ); + const storeClearTerminalLaunchContext = useTerminalStateStore( + (s) => s.clearTerminalLaunchContext, + ); const threads = useStore((state) => state.threads); const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); @@ -957,24 +969,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const handlePreparedPullRequestThread = useCallback( async (input: { branch: string; worktreePath: string | null }) => { - const targetThreadId = await openOrReuseProjectDraftThread({ + await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", }); - const setupScript = - input.worktreePath && activeProject ? setupProjectScript(activeProject.scripts) : null; - if (targetThreadId && input.worktreePath && setupScript) { - setPendingPullRequestSetupRequest({ - threadId: targetThreadId, - worktreePath: input.worktreePath, - scriptId: setupScript.id, - }); - } else { - setPendingPullRequestSetupRequest(null); - } }, - [activeProject, openOrReuseProjectDraftThread], + [openOrReuseProjectDraftThread], ); useEffect(() => { @@ -1543,6 +1544,12 @@ export default function ChatView({ threadId }: ChatViewProps) { () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], ); + const activeProjectCwd = activeProject?.cwd ?? null; + const activeThreadWorktreePath = activeThread?.worktreePath ?? null; + const activeTerminalLaunchContext = + terminalLaunchContext?.threadId === activeThreadId + ? terminalLaunchContext + : (storeServerTerminalLaunchContext ?? null); // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( @@ -1763,7 +1770,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; + const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; + setTerminalLaunchContext({ + threadId: activeThreadId, + cwd: targetCwd, + worktreePath: targetWorktreePath, + }); setTerminalOpen(true); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadId, targetTerminalId); @@ -1776,7 +1789,7 @@ export default function ChatView({ threadId }: ChatViewProps) { project: { cwd: activeProject.cwd, }, - worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal @@ -1784,6 +1797,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, @@ -1792,6 +1806,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, }; @@ -1825,44 +1840,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ], ); - useEffect(() => { - if (!pendingPullRequestSetupRequest || !activeProject || !activeThreadId || !activeThread) { - return; - } - if (pendingPullRequestSetupRequest.threadId !== activeThreadId) { - return; - } - if (activeThread.worktreePath !== pendingPullRequestSetupRequest.worktreePath) { - return; - } - - const setupScript = - activeProject.scripts.find( - (script) => script.id === pendingPullRequestSetupRequest.scriptId, - ) ?? null; - setPendingPullRequestSetupRequest(null); - if (!setupScript) { - return; - } - - void runProjectScript(setupScript, { - cwd: pendingPullRequestSetupRequest.worktreePath, - worktreePath: pendingPullRequestSetupRequest.worktreePath, - rememberAsLastInvoked: false, - }).catch((error) => { - toastManager.add({ - type: "error", - title: "Failed to run setup script.", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }); - }, [ - activeProject, - activeThread, - activeThreadId, - pendingPullRequestSetupRequest, - runProjectScript, - ]); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -2555,6 +2532,74 @@ export default function ChatView({ threadId }: ChatViewProps) { ? (draftThread?.envMode ?? "local") : "local"; + useEffect(() => { + if (!activeThreadId) { + setTerminalLaunchContext(null); + storeClearTerminalLaunchContext(threadId); + return; + } + setTerminalLaunchContext((current) => { + if (!current) return current; + if (current.threadId === activeThreadId) return current; + return null; + }); + }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); + + useEffect(() => { + if (!activeThreadId || !activeProjectCwd) { + return; + } + setTerminalLaunchContext((current) => { + if (!current || current.threadId !== activeThreadId) { + return current; + } + const settledCwd = projectScriptCwd({ + project: { cwd: activeProjectCwd }, + worktreePath: activeThreadWorktreePath, + }); + if ( + settledCwd === current.cwd && + (activeThreadWorktreePath ?? null) === current.worktreePath + ) { + storeClearTerminalLaunchContext(activeThreadId); + return null; + } + return current; + }); + }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + + useEffect(() => { + if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { + return; + } + const settledCwd = projectScriptCwd({ + project: { cwd: activeProjectCwd }, + worktreePath: activeThreadWorktreePath, + }); + if ( + settledCwd === storeServerTerminalLaunchContext.cwd && + (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath + ) { + storeClearTerminalLaunchContext(activeThreadId); + } + }, [ + activeProjectCwd, + activeThreadId, + activeThreadWorktreePath, + storeClearTerminalLaunchContext, + storeServerTerminalLaunchContext, + ]); + + useEffect(() => { + if (terminalState.terminalOpen) { + return; + } + if (activeThreadId) { + storeClearTerminalLaunchContext(activeThreadId); + } + setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); + }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); + useEffect(() => { if (phase !== "running") return; const timer = window.setInterval(() => { @@ -2969,36 +3014,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(0); setComposerTrigger(null); - let createdServerThreadForLocalDraft = false; let turnStartSucceeded = false; - let nextThreadBranch = activeThread.branch; - let nextThreadWorktreePath = activeThread.worktreePath; await (async () => { - // On first message: lock in branch + create worktree if needed. - if (baseBranchForWorktree) { - beginLocalDispatch({ preparingWorktree: true }); - const newBranch = buildTemporaryWorktreeBranchName(); - const result = await createWorktreeMutation.mutateAsync({ - cwd: activeProject.cwd, - branch: baseBranchForWorktree, - newBranch, - }); - nextThreadBranch = result.worktree.branch; - nextThreadWorktreePath = result.worktree.path; - if (isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - branch: result.worktree.branch, - worktreePath: result.worktree.path, - }); - // Keep local thread state in sync immediately so terminal drawer opens - // with the worktree cwd/env instead of briefly using the project root. - setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); - } - } - let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { const firstComposerImage = composerImagesSnapshot[0]; @@ -3026,48 +3043,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), } as ModelSelection; - if (isLocalDraftThread) { - await api.orchestration.dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId: threadIdForSend, - projectId: activeProject.id, - title, - modelSelection: threadCreateModelSelection, - runtimeMode, - interactionMode, - branch: nextThreadBranch, - worktreePath: nextThreadWorktreePath, - createdAt: activeThread.createdAt, - }); - createdServerThreadForLocalDraft = true; - } - - let setupScript: ProjectScript | null = null; - if (baseBranchForWorktree) { - setupScript = setupProjectScript(activeProject.scripts); - } - if (setupScript) { - let shouldRunSetupScript = false; - if (isServerThread) { - shouldRunSetupScript = true; - } else { - if (createdServerThreadForLocalDraft) { - shouldRunSetupScript = true; - } - } - if (shouldRunSetupScript) { - const setupScriptOptions: Parameters[1] = { - worktreePath: nextThreadWorktreePath, - rememberAsLastInvoked: false, - }; - if (nextThreadWorktreePath) { - setupScriptOptions.cwd = nextThreadWorktreePath; - } - await runProjectScript(setupScript, setupScriptOptions); - } - } - // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ @@ -3088,8 +3063,37 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } - beginLocalDispatch({ preparingWorktree: false }); const turnAttachments = await turnAttachmentsPromise; + const bootstrap = + isLocalDraftThread || baseBranchForWorktree + ? { + ...(isLocalDraftThread + ? { + createThread: { + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + createdAt: activeThread.createdAt, + }, + } + : {}), + ...(baseBranchForWorktree + ? { + prepareWorktree: { + projectCwd: activeProject.cwd, + baseBranch: baseBranchForWorktree, + branch: buildTemporaryWorktreeBranchName(), + }, + runSetupScript: true, + } + : {}), + } + : undefined; + beginLocalDispatch({ preparingWorktree: false }); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -3104,19 +3108,11 @@ export default function ChatView({ threadId }: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode, + ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { - if (createdServerThreadForLocalDraft && !turnStartSucceeded) { - await api.orchestration - .dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadIdForSend, - }) - .catch(() => undefined); - } if ( !turnStartSucceeded && promptRef.current.length === 0 && @@ -4442,6 +4438,7 @@ export default function ChatView({ threadId }: ChatViewProps) { { @@ -4484,6 +4481,9 @@ export default function ChatView({ threadId }: ChatViewProps) { mountedThreadId === activeThreadId && (terminalStateByThreadId[mountedThreadId]?.terminalOpen ?? false) } + launchContext={ + mountedThreadId === activeThreadId ? (activeTerminalLaunchContext ?? null) : null + } focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0} splitShortcutLabel={splitTerminalShortcutLabel ?? undefined} newShortcutLabel={newTerminalShortcutLabel ?? undefined} diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index c1c88e59ae..e124ada2b1 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,4 @@ -import type { GitResolvePullRequestResult } from "@t3tools/contracts"; +import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; interface PullRequestThreadDialogProps { open: boolean; + threadId: ThreadId; cwd: string | null; initialReference: string | null; onOpenChange: (open: boolean) => void; @@ -32,6 +33,7 @@ interface PullRequestThreadDialogProps { export function PullRequestThreadDialog({ open, + threadId, cwd, initialReference, onOpenChange, @@ -133,6 +135,7 @@ export function PullRequestThreadDialog({ const result = await preparePullRequestThreadMutation.mutateAsync({ reference: parsedReference, mode, + ...(mode === "worktree" ? { threadId } : {}), }); await onPrepared({ branch: result.branch, @@ -154,6 +157,7 @@ export function PullRequestThreadDialog({ parsedReference, preparePullRequestThreadMutation, resolvedPullRequest, + threadId, ], ); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 0ba15dd856..692d7f13fa 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -249,6 +249,7 @@ interface TerminalViewportProps { terminalId: string; terminalLabel: string; cwd: string; + worktreePath?: string | null; runtimeEnv?: Record; onSessionExited: () => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; @@ -263,6 +264,7 @@ function TerminalViewport({ terminalId, terminalLabel, cwd, + worktreePath, runtimeEnv, onSessionExited, onAddTerminalContext, @@ -634,6 +636,7 @@ function TerminalViewport({ threadId, terminalId, cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), cols: activeTerminal.cols, rows: activeTerminal.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), @@ -766,6 +769,7 @@ function TerminalViewport({ interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; + worktreePath?: string | null; runtimeEnv?: Record; visible?: boolean; height: number; @@ -817,6 +821,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA export default function ThreadTerminalDrawer({ threadId, cwd, + worktreePath, runtimeEnv, visible = true, height, @@ -1143,6 +1148,7 @@ export default function ThreadTerminalDrawer({ terminalId={terminalId} terminalLabel={terminalLabelById.get(terminalId) ?? "Terminal"} cwd={cwd} + {...(worktreePath !== undefined ? { worktreePath } : {})} {...(runtimeEnv ? { runtimeEnv } : {})} onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} @@ -1163,6 +1169,7 @@ export default function ThreadTerminalDrawer({ terminalId={resolvedActiveTerminalId} terminalLabel={terminalLabelById.get(resolvedActiveTerminalId) ?? "Terminal"} cwd={cwd} + {...(worktreePath !== undefined ? { worktreePath } : {})} {...(runtimeEnv ? { runtimeEnv } : {})} onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 53f04848c7..bfac623db9 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,8 @@ -import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts"; +import { + type GitActionProgressEvent, + type GitStackedAction, + type ThreadId, +} from "@t3tools/contracts"; import { infiniteQueryOptions, mutationOptions, @@ -244,13 +248,22 @@ export function gitPreparePullRequestThreadMutationOptions(input: { queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => { + mutationFn: async ({ + reference, + mode, + threadId, + }: { + reference: string; + mode: "local" | "worktree"; + threadId?: ThreadId; + }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); return api.git.preparePullRequestThread({ cwd: input.cwd, reference, mode, + ...(threadId ? { threadId } : {}), }); }, mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts index 08678f8730..1e05ff5ef6 100644 --- a/apps/web/src/projectScripts.test.ts +++ b/apps/web/src/projectScripts.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "vitest"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, - projectScriptCwd, - projectScriptRuntimeEnv, projectScriptIdFromCommand, - setupProjectScript, } from "./projectScripts"; describe("projectScripts helpers", () => { diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index c11c3923bc..03e3d29116 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -55,43 +55,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable) return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); } -interface ProjectScriptRuntimeEnvInput { - project: { - cwd: string; - }; - worktreePath?: string | null; - extraEnv?: Record; -} - -export function projectScriptCwd(input: { - project: { - cwd: string; - }; - worktreePath?: string | null; -}): string { - return input.worktreePath ?? input.project.cwd; -} - -export function projectScriptRuntimeEnv( - input: ProjectScriptRuntimeEnvInput, -): Record { - const env: Record = { - T3CODE_PROJECT_ROOT: input.project.cwd, - }; - if (input.worktreePath) { - env.T3CODE_WORKTREE_PATH = input.worktreePath; - } - if (input.extraEnv) { - return { ...env, ...input.extraEnv }; - } - return env; -} - export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { const regular = scripts.find((script) => !script.runOnWorktreeCreate); return regular ?? scripts[0] ?? null; } - -export function setupProjectScript(scripts: ProjectScript[]): ProjectScript | null { - return scripts.find((script) => script.runOnWorktreeCreate) ?? null; -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 9f98a7bc6b..977474d697 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,7 +1,7 @@ import { OrchestrationEvent, - ThreadId, type ServerLifecycleWelcomePayload, + type ThreadId, } from "@t3tools/contracts"; import { Outlet, @@ -36,7 +36,6 @@ import { import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; -import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; @@ -206,6 +205,7 @@ function EventRouter() { const removeOrphanedTerminalStates = useTerminalStateStore( (store) => store.removeOrphanedTerminalStates, ); + const applyTerminalEvent = useTerminalStateStore((store) => store.applyTerminalEvent); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); @@ -505,18 +505,7 @@ function EventRouter() { if (!thread || thread.archivedAt !== null) { return; } - useTerminalStateStore.getState().recordTerminalEvent(event); - const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); - if (hasRunningSubprocess === null) { - return; - } - useTerminalStateStore - .getState() - .setTerminalActivity( - ThreadId.makeUnsafe(event.threadId), - event.terminalId, - hasRunningSubprocess, - ); + applyTerminalEvent(event); }); return () => { disposed = true; @@ -534,6 +523,7 @@ function EventRouter() { queryClient, removeTerminalState, removeOrphanedTerminalStates, + applyTerminalEvent, clearThreadUi, setProjectExpanded, syncProjects, diff --git a/apps/web/src/terminalActivity.test.ts b/apps/web/src/terminalActivity.test.ts index 8fda326266..6f38dcbfcb 100644 --- a/apps/web/src/terminalActivity.test.ts +++ b/apps/web/src/terminalActivity.test.ts @@ -7,6 +7,7 @@ const snapshot: TerminalSessionSnapshot = { threadId: "thread-1", terminalId: "default", cwd: "/tmp", + worktreePath: null, status: "running", pid: 1234, history: "", diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 99f1ff2615..bfd7fbcb9a 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -72,6 +72,7 @@ function makeTerminalEvent( threadId: THREAD_ID, terminalId: "default", cwd: "/tmp/workspace", + worktreePath: null, status: "running", pid: 123, history: "", @@ -89,6 +90,7 @@ describe("terminalStateStore actions", () => { mockLocalStorage.clear(); useTerminalStateStore.setState({ terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -165,6 +167,23 @@ describe("terminalStateStore actions", () => { ]); }); + it("ensures unknown server terminals are registered, opened, and activated", () => { + const store = useTerminalStateStore.getState(); + store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true }); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + expect(terminalState.terminalOpen).toBe(true); + expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); + expect(terminalState.activeTerminalId).toBe("setup-setup"); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default"] }, + { id: "group-setup-setup", terminalIds: ["setup-setup"] }, + ]); + }); + it("allows unlimited groups while keeping each group capped at four terminals", () => { const store = useTerminalStateStore.getState(); store.splitTerminal(THREAD_ID, "terminal-2"); @@ -252,6 +271,84 @@ describe("terminalStateStore actions", () => { expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]); }); + it("applies started terminal events to terminal state, launch context, and event buffer", () => { + const store = useTerminalStateStore.getState(); + store.applyTerminalEvent( + makeTerminalEvent("started", { + terminalId: "setup-bootstrap", + snapshot: { + threadId: THREAD_ID, + terminalId: "setup-bootstrap", + cwd: "/tmp/worktree", + worktreePath: "/tmp/worktree", + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-04-02T20:00:00.000Z", + }, + }), + ); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "setup-bootstrap", + ); + + expect(terminalState.terminalOpen).toBe(true); + expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); + expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]); + expect(useTerminalStateStore.getState().terminalLaunchContextByThreadId[THREAD_ID]).toEqual({ + cwd: "/tmp/worktree", + worktreePath: "/tmp/worktree", + }); + expect(entries).toHaveLength(1); + expect(entries[0]?.event.type).toBe("started"); + }); + + it("applies activity and exited terminal events to subprocess state while buffering events", () => { + const store = useTerminalStateStore.getState(); + store.ensureTerminal(THREAD_ID, "terminal-2", { open: true, active: true }); + + store.applyTerminalEvent( + makeTerminalEvent("activity", { + terminalId: "terminal-2", + hasRunningSubprocess: true, + }), + ); + expect( + selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) + .runningTerminalIds, + ).toEqual(["terminal-2"]); + + store.applyTerminalEvent( + makeTerminalEvent("exited", { + terminalId: "terminal-2", + exitCode: 0, + exitSignal: null, + }), + ); + + const terminalState = selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadId, + THREAD_ID, + ); + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "terminal-2", + ); + + expect(terminalState.runningTerminalIds).toEqual([]); + expect(entries.map((entry) => entry.event.type)).toEqual(["activity", "exited"]); + }); + it("clears buffered terminal events when a thread terminal state is removed", () => { const store = useTerminalStateStore.getState(); store.recordTerminalEvent(makeTerminalEvent("output")); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index cd17aa295f..7189e715a4 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -5,10 +5,11 @@ * API constrained to store actions/selectors. */ -import type { TerminalEvent, ThreadId } from "@t3tools/contracts"; +import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +import { terminalRunningSubprocessFromEvent } from "./terminalActivity"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -26,6 +27,11 @@ interface ThreadTerminalState { activeTerminalGroupId: string; } +export interface ThreadTerminalLaunchContext { + cwd: string; + worktreePath: string | null; +} + export interface TerminalEventEntry { id: number; event: TerminalEvent; @@ -245,6 +251,40 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ })); } +function appendTerminalEventEntry( + terminalEventEntriesByKey: Record>, + nextTerminalEventId: number, + event: TerminalEvent, +) { + const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId); + const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; + const nextEntry: TerminalEventEntry = { + id: nextTerminalEventId, + event, + }; + const nextEntries = + currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER + ? [...currentEntries.slice(1), nextEntry] + : [...currentEntries, nextEntry]; + + return { + terminalEventEntriesByKey: { + ...terminalEventEntriesByKey, + [key]: nextEntries, + }, + nextTerminalEventId: nextTerminalEventId + 1, + }; +} + +function launchContextFromStartEvent( + event: Extract, +): ThreadTerminalLaunchContext { + return { + cwd: event.snapshot.cwd, + worktreePath: event.snapshot.worktreePath, + }; +} + function upsertTerminalIntoGroups( state: ThreadTerminalState, terminalId: string, @@ -498,20 +538,29 @@ export function selectTerminalEventEntries( interface TerminalStateStoreState { terminalStateByThreadId: Record; + terminalLaunchContextByThreadId: Record; terminalEventEntriesByKey: Record>; nextTerminalEventId: number; setTerminalOpen: (threadId: ThreadId, open: boolean) => void; setTerminalHeight: (threadId: ThreadId, height: number) => void; splitTerminal: (threadId: ThreadId, terminalId: string) => void; newTerminal: (threadId: ThreadId, terminalId: string) => void; + ensureTerminal: ( + threadId: ThreadId, + terminalId: string, + options?: { open?: boolean; active?: boolean }, + ) => void; setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; closeTerminal: (threadId: ThreadId, terminalId: string) => void; + setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void; + clearTerminalLaunchContext: (threadId: ThreadId) => void; setTerminalActivity: ( threadId: ThreadId, terminalId: string, hasRunningSubprocess: boolean, ) => void; recordTerminalEvent: (event: TerminalEvent) => void; + applyTerminalEvent: (event: TerminalEvent) => void; clearTerminalState: (threadId: ThreadId) => void; removeTerminalState: (threadId: ThreadId) => void; removeOrphanedTerminalStates: (activeThreadIds: Set) => void; @@ -541,6 +590,7 @@ export const useTerminalStateStore = create()( return { terminalStateByThreadId: {}, + terminalLaunchContextByThreadId: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, setTerminalOpen: (threadId, open) => @@ -551,33 +601,104 @@ export const useTerminalStateStore = create()( updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), newTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), + ensureTerminal: (threadId, terminalId, options) => + updateTerminal(threadId, (state) => { + let nextState = state; + if (!state.terminalIds.includes(terminalId)) { + nextState = newThreadTerminal(nextState, terminalId); + } + if (options?.active === false) { + nextState = { + ...nextState, + activeTerminalId: state.activeTerminalId, + activeTerminalGroupId: state.activeTerminalGroupId, + }; + } + if (options?.active ?? true) { + nextState = setThreadActiveTerminal(nextState, terminalId); + } + if (options?.open) { + nextState = setThreadTerminalOpen(nextState, true); + } + return normalizeThreadTerminalState(nextState); + }), setActiveTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), closeTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), + setTerminalLaunchContext: (threadId, context) => + set((state) => ({ + terminalLaunchContextByThreadId: { + ...state.terminalLaunchContextByThreadId, + [threadId]: context, + }, + })), + clearTerminalLaunchContext: (threadId) => + set((state) => { + if (!state.terminalLaunchContextByThreadId[threadId]) { + return state; + } + const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId; + return { terminalLaunchContextByThreadId: rest }; + }), setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => updateTerminal(threadId, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), recordTerminalEvent: (event) => + set((state) => + appendTerminalEventEntry( + state.terminalEventEntriesByKey, + state.nextTerminalEventId, + event, + ), + ), + applyTerminalEvent: (event) => set((state) => { - const key = terminalEventBufferKey(event.threadId as ThreadId, event.terminalId); - const currentEntries = - state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; - const nextEntry: TerminalEventEntry = { - id: state.nextTerminalEventId, + const threadId = ThreadId.makeUnsafe(event.threadId); + let nextTerminalStateByThreadId = state.terminalStateByThreadId; + let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId; + + if (event.type === "started" || event.type === "restarted") { + nextTerminalStateByThreadId = updateTerminalStateByThreadId( + nextTerminalStateByThreadId, + threadId, + (current) => { + let nextState = current; + if (!current.terminalIds.includes(event.terminalId)) { + nextState = newThreadTerminal(nextState, event.terminalId); + } + nextState = setThreadActiveTerminal(nextState, event.terminalId); + nextState = setThreadTerminalOpen(nextState, true); + return normalizeThreadTerminalState(nextState); + }, + ); + nextTerminalLaunchContextByThreadId = { + ...nextTerminalLaunchContextByThreadId, + [threadId]: launchContextFromStartEvent(event), + }; + } + + const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); + if (hasRunningSubprocess !== null) { + nextTerminalStateByThreadId = updateTerminalStateByThreadId( + nextTerminalStateByThreadId, + threadId, + (current) => + setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), + ); + } + + const nextEventState = appendTerminalEventEntry( + state.terminalEventEntriesByKey, + state.nextTerminalEventId, event, - }; - const nextEntries = - currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER - ? [...currentEntries.slice(1), nextEntry] - : [...currentEntries, nextEntry]; + ); + return { - terminalEventEntriesByKey: { - ...state.terminalEventEntriesByKey, - [key]: nextEntries, - }, - nextTerminalEventId: state.nextTerminalEventId + 1, + terminalStateByThreadId: nextTerminalStateByThreadId, + terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId, + ...nextEventState, }; }), clearTerminalState: (threadId) => @@ -587,6 +708,9 @@ export const useTerminalStateStore = create()( threadId, () => createDefaultThreadTerminalState(), ); + const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; + const { [threadId]: _removed, ...remainingLaunchContexts } = + state.terminalLaunchContextByThreadId; const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { @@ -597,18 +721,21 @@ export const useTerminalStateStore = create()( } if ( nextTerminalStateByThreadId === state.terminalStateByThreadId && + !hadLaunchContext && !removedEventEntries ) { return state; } return { terminalStateByThreadId: nextTerminalStateByThreadId, + terminalLaunchContextByThreadId: remainingLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), removeTerminalState: (threadId) => set((state) => { - const hasThreadState = state.terminalStateByThreadId[threadId] !== undefined; + const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined; + const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { @@ -617,13 +744,16 @@ export const useTerminalStateStore = create()( removedEventEntries = true; } } - if (!hasThreadState && !removedEventEntries) { + if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) { return state; } - const next = { ...state.terminalStateByThreadId }; - delete next[threadId]; + const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId }; + delete nextTerminalStateByThreadId[threadId]; + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; + delete nextLaunchContexts[threadId]; return { - terminalStateByThreadId: next, + terminalStateByThreadId: nextTerminalStateByThreadId, + terminalLaunchContextByThreadId: nextLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), @@ -632,6 +762,9 @@ export const useTerminalStateStore = create()( const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( (id) => !activeThreadIds.has(id as ThreadId), ); + const orphanedLaunchContextIds = Object.keys( + state.terminalLaunchContextByThreadId, + ).filter((id) => !activeThreadIds.has(id as ThreadId)); const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { @@ -641,13 +774,24 @@ export const useTerminalStateStore = create()( removedEventEntries = true; } } - if (orphanedIds.length === 0 && !removedEventEntries) return state; + if ( + orphanedIds.length === 0 && + orphanedLaunchContextIds.length === 0 && + !removedEventEntries + ) { + return state; + } const next = { ...state.terminalStateByThreadId }; for (const id of orphanedIds) { delete next[id as ThreadId]; } + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; + for (const id of orphanedLaunchContextIds) { + delete nextLaunchContexts[id as ThreadId]; + } return { terminalStateByThreadId: next, + terminalLaunchContextByThreadId: nextLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index b72d6f168d..a9e13b511c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; import { ProviderKind } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -150,6 +150,7 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, reference: GitPullRequestReference, mode: GitPreparePullRequestThreadMode, + threadId: Schema.optional(ThreadId), }); export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 60a08725de..c58facdd30 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -191,6 +191,47 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( }), ); +it.effect("accepts bootstrap metadata in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-bootstrap", + threadId: "thread-1", + message: { + messageId: "msg-bootstrap", + role: "user", + text: "hello", + attachments: [], + }, + bootstrap: { + createThread: { + projectId: "project-1", + title: "Bootstrap thread", + modelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + prepareWorktree: { + projectCwd: "/tmp/workspace", + baseBranch: "main", + branch: "t3code/example", + }, + runSetupScript: true, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.bootstrap?.createThread?.projectId, "project-1"); + assert.strictEqual(parsed.bootstrap?.prepareWorktree?.baseBranch, "main"); + assert.strictEqual(parsed.bootstrap?.runSetupScript, true); + }), +); + it.effect("decodes thread.created runtime mode for historical events", () => Effect.gen(function* () { const parsed = yield* decodeThreadCreatedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index fd3e878c85..247c86ac15 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -553,6 +553,31 @@ const ThreadInteractionModeSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + createdAt: IsoDateTime, +}); + +const ThreadTurnStartBootstrapPrepareWorktree = Schema.Struct({ + projectCwd: TrimmedNonEmptyString, + baseBranch: TrimmedNonEmptyString, + branch: Schema.optional(TrimmedNonEmptyString), +}); + +const ThreadTurnStartBootstrap = Schema.Struct({ + createThread: Schema.optional(ThreadTurnStartBootstrapCreateThread), + prepareWorktree: Schema.optional(ThreadTurnStartBootstrapPrepareWorktree), + runSetupScript: Schema.optional(Schema.Boolean), +}); + +export type ThreadTurnStartBootstrap = typeof ThreadTurnStartBootstrap.Type; + export const ThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, @@ -569,6 +594,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), ), + bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); @@ -587,6 +613,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, + bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index 614df05141..1bef8db3a0 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -63,6 +63,7 @@ describe("TerminalOpenInput", () => { const parsed = decodeSync(TerminalOpenInput, { threadId: "thread-1", cwd: "/tmp/project", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", cols: 100, rows: 24, env: { @@ -74,6 +75,7 @@ describe("TerminalOpenInput", () => { T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }); + expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); }); it("rejects invalid env keys", () => { @@ -157,6 +159,7 @@ describe("TerminalSessionSnapshot", () => { threadId: "thread-1", terminalId: DEFAULT_TERMINAL_ID, cwd: "/tmp/project", + worktreePath: null, status: "running", pid: 1234, history: "hello\n", @@ -205,4 +208,27 @@ describe("TerminalEvent", () => { }), ).toBe(true); }); + + it("accepts started events with snapshot worktree metadata", () => { + expect( + decodes(TerminalEvent, { + type: "started", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + createdAt: new Date().toISOString(), + snapshot: { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project/.t3/worktrees/feature-a", + worktreePath: "/tmp/project/.t3/worktrees/feature-a", + status: "running", + pid: 1234, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: new Date().toISOString(), + }, + }), + ).toBe(true); + }); }); diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index 4344706bc2..8b20254def 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -37,6 +37,7 @@ export type TerminalSessionInput = Schema.Codec.Encoded export const TerminalRestartInput = Schema.Struct({ ...TerminalSessionInput.fields, cwd: TrimmedNonEmptyStringSchema, + worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), @@ -82,6 +84,7 @@ export const TerminalSessionSnapshot = Schema.Struct({ threadId: Schema.String.check(Schema.isNonEmpty()), terminalId: Schema.String.check(Schema.isNonEmpty()), cwd: Schema.String.check(Schema.isNonEmpty()), + worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema), status: TerminalSessionStatus, pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), history: Schema.String, diff --git a/packages/shared/package.json b/packages/shared/package.json index a80b514dd2..b71d52d700 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -47,6 +47,10 @@ "./String": { "types": "./src/String.ts", "import": "./src/String.ts" + }, + "./projectScripts": { + "types": "./src/projectScripts.ts", + "import": "./src/projectScripts.ts" } }, "scripts": { diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts new file mode 100644 index 0000000000..199a55bf3c --- /dev/null +++ b/packages/shared/src/projectScripts.ts @@ -0,0 +1,37 @@ +import type { ProjectScript } from "@t3tools/contracts"; + +interface ProjectScriptRuntimeEnvInput { + project: { + cwd: string; + }; + worktreePath?: string | null; + extraEnv?: Record; +} + +export function projectScriptCwd(input: { + project: { + cwd: string; + }; + worktreePath?: string | null; +}): string { + return input.worktreePath ?? input.project.cwd; +} + +export function projectScriptRuntimeEnv( + input: ProjectScriptRuntimeEnvInput, +): Record { + const env: Record = { + T3CODE_PROJECT_ROOT: input.project.cwd, + }; + if (input.worktreePath) { + env.T3CODE_WORKTREE_PATH = input.worktreePath; + } + if (input.extraEnv) { + return { ...env, ...input.extraEnv }; + } + return env; +} + +export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { + return scripts.find((script) => script.runOnWorktreeCreate) ?? null; +} From b329139821aee96a9d3841cd4b73da6efe6a6575 Mon Sep 17 00:00:00 2001 From: sherlock Date: Sun, 5 Apr 2026 09:43:26 +0530 Subject: [PATCH 2/2] Address CodeRabbit review: worktree metadata preservation, hydration guard, conflicting selectors, and dependency fix - ProjectSetupScriptRunner: validate that projectId and projectCwd resolve to the same project when both are provided - ChatView: only clear terminal launch context on actual thread switch, not during hydration when activeThreadId is falsy - Terminal Manager open(): update worktreePath for already-running sessions even when cwd/env are unchanged - Terminal Manager restart(): preserve existing worktreePath when input omits it - ThreadTerminalDrawer: add worktreePath to effect dependency list so terminal reinitializes when the path changes --- .../Layers/ProjectSetupScriptRunner.ts | 24 ++++++++++++------- apps/server/src/terminal/Layers/Manager.ts | 20 ++++++++++++---- apps/web/src/components/ChatView.tsx | 3 +-- .../src/components/ThreadTerminalDrawer.tsx | 2 +- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts index 3bac8cf0ab..f5cae3e7b4 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.ts @@ -15,14 +15,22 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () { const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) => Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); - const project = - (input.projectId - ? readModel.projects.find((entry) => entry.id === input.projectId) - : null) ?? - (input.projectCwd - ? readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd) - : null) ?? - null; + const projectById = input.projectId + ? (readModel.projects.find((entry) => entry.id === input.projectId) ?? null) + : null; + const projectByCwd = input.projectCwd + ? (readModel.projects.find((entry) => entry.workspaceRoot === input.projectCwd) ?? null) + : null; + + if (projectById && projectByCwd && projectById.id !== projectByCwd.id) { + return yield* Effect.fail( + new Error( + `Conflicting project selectors: projectId "${input.projectId}" resolves to project "${projectById.id}" but projectCwd "${input.projectCwd}" resolves to project "${projectByCwd.id}".`, + ), + ); + } + + const project = projectById ?? projectByCwd ?? null; if (!project) { return yield* Effect.fail(new Error("Project was not found for setup script execution.")); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index f4cf0a6362..86f72f1333 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1645,10 +1645,16 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const targetRows = input.rows ?? liveSession.rows; const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); + const nextWorktreePath = + input.worktreePath === undefined + ? liveSession.worktreePath + : (input.worktreePath ?? null); + const worktreePathChanged = liveSession.worktreePath !== nextWorktreePath; + if (liveSession.cwd !== input.cwd || runtimeEnvChanged) { yield* stopProcess(liveSession); liveSession.cwd = input.cwd; - liveSession.worktreePath = input.worktreePath ?? null; + liveSession.worktreePath = nextWorktreePath; liveSession.runtimeEnv = nextRuntimeEnv; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; @@ -1662,7 +1668,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith ); } else if (liveSession.status === "exited" || liveSession.status === "error") { liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.worktreePath = input.worktreePath ?? null; + liveSession.worktreePath = nextWorktreePath; liveSession.history = ""; liveSession.pendingHistoryControlSequence = ""; liveSession.pendingProcessEvents = []; @@ -1673,6 +1679,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith liveSession.terminalId, liveSession.history, ); + } else if (worktreePathChanged) { + liveSession.worktreePath = nextWorktreePath; + liveSession.updatedAt = new Date().toISOString(); } if (!liveSession.process) { @@ -1805,7 +1814,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session = existingSession.value; yield* stopProcess(session); session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; + session.worktreePath = + input.worktreePath === undefined + ? session.worktreePath + : (input.worktreePath ?? null); session.runtimeEnv = normalizedRuntimeEnv(input.env); } @@ -1825,7 +1837,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith threadId: input.threadId, terminalId, cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + worktreePath: session.worktreePath, cols, rows, ...(input.env ? { env: input.env } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3933fa35b4..8641b7897f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2534,13 +2534,12 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activeThreadId) { - setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(threadId); return; } setTerminalLaunchContext((current) => { if (!current) return current; if (current.threadId === activeThreadId) return current; + storeClearTerminalLaunchContext(threadId); return null; }); }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 692d7f13fa..902f404355 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -723,7 +723,7 @@ function TerminalViewport({ // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cwd, runtimeEnv, terminalId, threadId]); + }, [cwd, runtimeEnv, terminalId, threadId, worktreePath]); useEffect(() => { if (!autoFocus) return;