From eb3ca83698d3712a11adee28df8a83a3b7b300d6 Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:42:22 -0700 Subject: [PATCH 1/5] feat(server): run a project action when a thread settles A project action can now be marked to run on settle. When a thread in its own worktree settles, manually or automatically, the server runs that action in the worktree, so a command like `cargo clean` frees the build output of finished work. Threads in the project's main checkout skip it. The action is set from the action editor or with `runOnSettle` in t3.json, shows an "on settle" badge, and a project has at most one, like the setup action. The setup script runner now waits for the shell's prompt after its exit sentinel before closing an idle shell; closing at the sentinel read the prompt redraw as new activity and left a fast script's shell open. --- .../src/features/terminal/terminalMenu.ts | 4 - .../features/threads/ThreadGitControls.tsx | 2 +- .../ThreadSettlementService.test.ts | 101 +++++++++++++++++- .../ThreadSettlementService.ts | 33 +++++- .../project/ProjectSetupScriptRunner.test.ts | 63 ++++++++++- .../src/project/ProjectSetupScriptRunner.ts | 34 +++++- apps/web/src/components/ChatView.tsx | 19 ++-- .../src/components/ProjectScriptsControl.tsx | 6 +- .../src/components/projectScriptEditor.tsx | 13 +++ .../settings/ProjectActionsList.tsx | 5 + .../settings/ProjectActionsSettings.tsx | 1 + .../settings/useProjectScriptSettings.ts | 7 +- apps/web/src/projectScripts.test.ts | 63 ++++++++++- apps/web/src/projectScripts.ts | 24 ++++- docs/user/thread-sidebar.md | 6 ++ packages/contracts/src/project.ts | 2 + packages/contracts/src/t3ProjectFile.ts | 6 ++ packages/shared/src/projectScripts.ts | 13 +++ 18 files changed, 363 insertions(+), 39 deletions(-) diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 47f3d08144ad..1adec0d03973 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -156,10 +156,6 @@ export function resolveProjectScriptTerminalId(input: { return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix); } -export function projectScriptMenuLabel(script: ProjectScript): string { - return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; -} - export function projectScriptMenuIcon(icon: ProjectScript["icon"]) { if (icon === "test") return "flask"; if (icon === "lint") return "checklist"; diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 737a6906b424..257fe6272de5 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -23,9 +23,9 @@ import { basename, getTerminalStatusLabel, projectScriptMenuIcon, - projectScriptMenuLabel, type TerminalMenuSession, } from "../terminal/terminalMenu"; +import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts"; function truncateMiddle(value: string, maxLength: number): string { if (value.length <= maxLength) { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index a7dd2a89c0bb..79310f385e6a 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -30,6 +30,7 @@ import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; import * as GitManager from "../git/GitManager.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerActivation from "../serverActivation.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -486,6 +487,10 @@ interface HarnessOptions { readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; readonly existingWorktreePaths?: ReadonlyArray; readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect; + /** Runs before each settle script start; a failure fails that start. */ + readonly onScriptRun?: ( + input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput, + ) => Effect.Effect; /** Threads `getThread` returns when a `thread.settled` event is handled. */ readonly currentThreads?: ReadonlyArray; } @@ -514,6 +519,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const invalidatedCwds = yield* Ref.make>([]); const domainEvents = yield* PubSub.unbounded(); const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>(); + const scriptRuns = + yield* Queue.unbounded(); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { @@ -601,6 +608,13 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Layer.mock(TerminalManager.TerminalManager)({ closeIdle: (input) => Queue.offer(closedIdle, input).pipe(Effect.asVoid), }), + Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({ + runForThread: (input) => + Queue.offer(scriptRuns, input).pipe( + Effect.andThen(options.onScriptRun?.(input) ?? Effect.void), + Effect.as({ status: "no-script" } as const), + ), + }), Layer.mock(GitManager.GitManager)({ branchPullRequest, invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), @@ -631,6 +645,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: summaryRecovery, invalidatedCwds, closedIdle, + scriptRuns, publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event), updateSettings, publishMerge: PubSub.publish(mergedPullRequests, { @@ -1010,6 +1025,7 @@ describe("ThreadSettlementServiceV2 terminals", () => { const appThread = ( id: string, settledOverride: OrchestrationV2AppThread["settledOverride"], + worktreePath: string | null = null, ): OrchestrationV2AppThread => { const threadId = ThreadId.make(id); return { @@ -1023,7 +1039,7 @@ describe("ThreadSettlementServiceV2 terminals", () => { runtimeMode: "full-access", interactionMode: "default", branch: null, - worktreePath: null, + worktreePath, activeProviderThreadId: null, lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, forkedFrom: null, @@ -1086,6 +1102,89 @@ describe("ThreadSettlementServiceV2 terminals", () => { }), ), ); + + it.effect("runs the settle script in the thread's own worktree", () => + Effect.scoped( + Effect.gen(function* () { + const shared = appThread("shared-checkout-thread", "settled"); + const missing = appThread("removed-worktree-thread", "settled", "/worktrees/removed"); + const worktree = appThread("worktree-thread", "settled", "/worktrees/thread"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + currentThreads: [shared, missing, worktree], + existingWorktreePaths: ["/worktrees/thread"], + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + // Events run in order, so the first run belongs to the last event + // only if the shared checkout and the removed worktree were skipped. + yield* fixture.publishEvent(settledEvent(shared)); + yield* fixture.publishEvent(settledEvent(missing)); + yield* fixture.publishEvent(settledEvent(worktree)); + const run = yield* Queue.take(fixture.scriptRuns); + assert.strictEqual(run.threadId, worktree.id); + assert.strictEqual(run.worktreePath, "/worktrees/thread"); + assert.strictEqual(run.trigger, "settle"); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("runs the settle script once when a settlement is re-emitted", () => + Effect.scoped( + Effect.gen(function* () { + const repeated = appThread("repeated-thread", "settled", "/worktrees/repeated"); + const marker = appThread("marker-thread", "settled", "/worktrees/marker"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + currentThreads: [repeated, marker], + existingWorktreePaths: ["/worktrees/repeated", "/worktrees/marker"], + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + // Settling an already settled thread emits the same settlement again. + yield* fixture.publishEvent(settledEvent(repeated)); + yield* fixture.publishEvent(settledEvent(repeated)); + yield* fixture.publishEvent(settledEvent(marker)); + assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, repeated.id); + assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("retries the settle script on a re-emitted settlement after a failed start", () => + Effect.scoped( + Effect.gen(function* () { + const thread = appThread("failed-start-thread", "settled", "/worktrees/failed-start"); + const starts = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + currentThreads: [thread], + existingWorktreePaths: ["/worktrees/failed-start"], + onScriptRun: () => + Ref.updateAndGet(starts, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 ? Effect.die(new Error("terminal failed to open")) : Effect.void, + ), + ), + }); + + yield* Effect.gen(function* () { + const service = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(service, fixture.activation, fixture.snapshotReads); + yield* fixture.publishEvent(settledEvent(thread)); + yield* fixture.publishEvent(settledEvent(thread)); + assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id); + assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); }); describe("ThreadSettlementServiceV2 single-thread sweeps", () => { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 7c64ce02804a..6617bde8efb9 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -20,6 +20,7 @@ import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts"; +import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerSettings from "../serverSettings.ts"; import { forkParked } from "../serverActivation.ts"; @@ -265,6 +266,10 @@ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const terminals = yield* TerminalManager.TerminalManager; + const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + // Settling a settled thread re-emits thread.settled with the same settledAt, + // so this keeps the settle action to one run per settlement. + const settleActionRunAt = new Map(); const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, @@ -514,20 +519,40 @@ export const make = Effect.gen(function* () { // Settling closes the thread's shells that sit at an idle prompt, so they stop // holding the worktree. A terminal running a command (a dev server, an - // editor) stays for the user to close. - const closeIdleTerminals = Effect.fn("ThreadSettlementServiceV2.closeIdleTerminals")( + // editor) stays for the user to close. Then the project's settle script runs + // in the thread's own worktree; a thread in the shared checkout skips it, + // because other threads may still be working there. + const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")( function* (threadId: ThreadId) { // A thread re-engaged before this event ran keeps its shells. const thread = yield* projections.getThread(threadId); if (thread.settledOverride !== "settled") return; yield* terminals.closeIdle({ threadId }); + const settledAtMs = toMillis(thread.settledAt); + if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return; + if (thread.worktreePath === null || !(yield* fileSystem.exists(thread.worktreePath))) { + return; + } + const run = yield* projectScripts.runForThread({ + threadId, + projectId: thread.projectId, + worktreePath: thread.worktreePath, + trigger: "settle", + // A clean exit closes the script's shell so it does not hold the worktree. + observeCompletion: {}, + }); + // Recorded after a successful start, so a failed start retries on the next event. + settleActionRunAt.set(threadId, settledAtMs); + if (run.status === "started" && run.completion) { + yield* run.completion.pipe(Effect.forkDetach); + } }, (effect, threadId) => effect.pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) - : Effect.logWarning("closing idle terminals after settlement failed", { + : Effect.logWarning("cleaning up a settled thread failed", { threadId, cause: Cause.pretty(cause), }), @@ -538,7 +563,7 @@ export const make = Effect.gen(function* () { const processEvent = (event: OrchestrationV2DomainEvent) => { switch (event.type) { case "thread.settled": - return closeIdleTerminals(event.threadId); + return cleanUpSettledThread(event.threadId); case "thread.pull-request-synced": case "provider-session.detached": return worker.enqueue(event.threadId); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 01976a173598..a11fb1f42d15 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, it, vi } from "@effect/vitest"; import { ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -29,6 +30,9 @@ it.effect("resolves setup scripts through the standalone project service", () => const write = vi.fn( (_input: Parameters[0]) => Effect.void, ); + const closeIdle = vi.fn( + (_input: Parameters[0]) => Effect.void, + ); const listeners: Array[0]> = []; const subscribe: TerminalManager.TerminalManager["Service"]["subscribe"] = (listener) => @@ -52,6 +56,14 @@ it.effect("resolves setup scripts through the standalone project service", () => icon: "configure" as const, runOnWorktreeCreate: true, }, + { + id: "clean", + name: "Clean", + command: "cargo clean", + icon: "build" as const, + runOnWorktreeCreate: false, + runOnSettle: true, + }, ], createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T00:00:00.000Z", @@ -63,7 +75,7 @@ it.effect("resolves setup scripts through the standalone project service", () => Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeed(Option.some(project)), }), - Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe }), + Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe, closeIdle }), ServerSettings.layerTest(), NodeCrypto.layer, ), @@ -117,5 +129,54 @@ it.effect("resolves setup scripts through the standalone project service", () => }); assert.deepEqual(lines, ["Downloading 10%", "Downloading 20%", "Done"]); yield* listener({ type: "closed", threadId: "thread-1", terminalId: "setup-setup" }); + + const settle = yield* runner.runForThread({ + threadId: "thread-1", + projectId, + worktreePath: "/repo-worktree", + trigger: "settle", + }); + const settleTerminalId = settle.status === "started" ? settle.terminalId : ""; + assert.match(settleTerminalId, /^settle-clean-/); + assert.equal(write.mock.calls.at(-1)?.[0].data, "cargo clean\r"); + + // A clean run closes its shell once the prompt is back, not at the + // sentinel, so the prompt redraw is not taken for new activity. + const observedSettle = yield* runner.runForThread({ + threadId: "thread-1", + projectId, + worktreePath: "/repo-worktree", + trigger: "settle", + observeCompletion: {}, + }); + const observedTerminalId = observedSettle.status === "started" ? observedSettle.terminalId : ""; + // Each settle gets its own shell, so a busy one is never typed into. + assert.notEqual(observedTerminalId, settleTerminalId); + const token = /__T3_SETUP_DONE___(\w+):/.exec(write.mock.calls.at(-1)?.[0].data ?? "")?.[1]; + const settleListener = listeners.at(-1)!; + const completion = yield* Effect.forkChild( + observedSettle.status === "started" && observedSettle.completion + ? observedSettle.completion + : Effect.die("no completion"), + ); + yield* settleListener({ + type: "output", + threadId: "thread-1", + terminalId: observedTerminalId, + data: `\r\n__T3_SETUP_DONE___${token}:0\r\n`, + }); + yield* Effect.yieldNow; + assert.equal(closeIdle.mock.calls.length, 0); + yield* settleListener({ + type: "output", + threadId: "thread-1", + terminalId: observedTerminalId, + data: "$ ", + }); + assert.deepEqual((yield* Fiber.join(completion)).exitCode, 0); + assert.deepEqual(closeIdle.mock.calls[0]?.[0], { + threadId: "thread-1", + terminalId: observedTerminalId, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 7b101226d5d7..426d290654df 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -3,6 +3,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { projectScriptRuntimeEnv, resolveProjectScripts, + settleProjectScript, setupProjectScript, } from "@t3tools/shared/projectScripts"; @@ -60,6 +61,8 @@ export interface ProjectSetupScriptRunnerInput { readonly projectCwd?: string; readonly worktreePath: string; readonly preferredTerminalId?: string; + /** Which project script to run. Defaults to the worktree setup script. */ + readonly trigger?: "setup" | "settle"; readonly project?: { readonly id: ProjectId; readonly workspaceRoot: string; @@ -225,6 +228,9 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; const done = yield* Deferred.make(); + // The shell redraws its prompt just after the sentinel. Closing before + // that would read the redraw as new activity and keep an idle shell. + const promptReturned = yield* Deferred.make(); let lineBuffer = ""; let settled = false; @@ -278,15 +284,26 @@ export const make = Effect.gen(function* () { if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) { lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH); } - return Effect.forEach(lines, handleLine, { discard: true }); + return Effect.forEach(lines, handleLine, { discard: true }).pipe( + // A prompt has no newline, so it is what remains once the sentinel is in. + Effect.andThen( + Effect.suspend(() => + settled && lineBuffer.length > 0 + ? Deferred.succeed(promptReturned, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ); } if (event.type === "exited" || event.type === "closed") { - return settle(null); + return settle(null).pipe(Effect.andThen(Deferred.succeed(promptReturned, undefined))); } return Effect.void; }); const completion = Deferred.await(done).pipe( + // A shell with an empty prompt never prints one; do not wait forever. + Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))), Effect.ensuring(Effect.sync(() => unsubscribe())), ); return { completion, unsubscribe }; @@ -348,14 +365,23 @@ export const make = Effect.gen(function* () { }), ), ); - const script = setupProjectScript(resolveProjectScripts(settings, project)); + const trigger = input.trigger ?? "setup"; + const scripts = resolveProjectScripts(settings, project); + const script = + trigger === "settle" ? settleProjectScript(scripts) : setupProjectScript(scripts); if (!script) { return { status: "no-script", } as const; } - const terminalId = input.preferredTerminalId ?? `setup-${script.id}`; + // A thread settles again after it is resumed, and an earlier settle shell + // may still be busy; typing into it would feed its foreground program. + const terminalId = + input.preferredTerminalId ?? + (trigger === "settle" + ? `settle-${script.id}-${NodeCrypto.randomUUID().slice(0, 8)}` + : `setup-${script.id}`); const cwd = input.worktreePath; const env = { ...projectScriptRuntimeEnv({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7fdc5e98a479..e6e1c7923319 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -320,6 +320,7 @@ import { commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, + releaseClaimedRoles, } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; @@ -5231,14 +5232,10 @@ export default function ChatView(props: ChatViewProps) { activeProjectScripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...activeProjectScripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...activeProjectScripts, nextScript]; + const nextScripts = [ + ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)), + nextScript, + ]; return persistProjectScripts({ projectId: activeProject.id, @@ -5266,11 +5263,7 @@ export default function ChatView(props: ChatViewProps) { const updatedScript = buildProjectScript(existingScript.id, input); const nextScripts = activeProjectScripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, + script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input), ); return persistProjectScripts({ diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index c51be0694343..ddcc8f7bc2e3 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -12,6 +12,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts"; import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react"; import React, { useCallback, useMemo, useState } from "react"; @@ -147,6 +148,7 @@ export default function ProjectScriptsControl({ icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false, + runOnSettle: fileScript.runOnSettle ?? false, ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}), previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, @@ -202,9 +204,7 @@ export default function ProjectScriptsControl({ onClick={() => onRunScript?.(script)} > - - {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} - + {projectScriptMenuLabel(script)} {shortcutLabel && (presentation === "menu" ? ( diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index a8a7e5e900e3..19a246cf4be4 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -92,6 +92,7 @@ export interface NewProjectScriptInput { runOnWorktreeCreate: boolean; /** Setup scripts only: hold the agent until the script exits. */ waitForSetup: boolean; + runOnSettle: boolean; /** Omit to preserve the current shortcut when the form did not edit it. */ keybinding?: string | null; /** Optional URL to open in the in-app preview when this script runs. */ @@ -108,6 +109,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = { icon: "play", runOnWorktreeCreate: false, waitForSetup: false, + runOnSettle: false, keybinding: null, previewUrl: null, autoOpenPreview: false, @@ -133,6 +135,7 @@ export function editorRequestForScript( icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, waitForSetup: script.runOnWorktreeCreate && script.async === false, + runOnSettle: script.runOnSettle ?? false, keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)), previewUrl: script.previewUrl ?? null, autoOpenPreview: script.autoOpenPreview ?? false, @@ -175,6 +178,7 @@ export function ProjectScriptEditorDialog({ const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); const [waitForSetup, setWaitForSetup] = useState(false); + const [runOnSettle, setRunOnSettle] = useState(false); const [keybinding, setKeybinding] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); @@ -206,6 +210,7 @@ export function ProjectScriptEditorDialog({ setIconPickerOpen(false); setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate); setWaitForSetup(request.initial.waitForSetup); + setRunOnSettle(request.initial.runOnSettle); setKeybinding(request.initial.keybinding ?? ""); setPreviewUrl(request.initial.previewUrl ?? ""); setAutoOpenPreview(request.initial.autoOpenPreview); @@ -279,6 +284,7 @@ export function ProjectScriptEditorDialog({ icon, runOnWorktreeCreate, waitForSetup: runOnWorktreeCreate && waitForSetup, + runOnSettle, ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding ? { keybinding: keybindingRule?.key ?? null } : {}), @@ -443,6 +449,13 @@ export function ProjectScriptEditorDialog({ onCheckedChange={(checked) => setWaitForSetup(Boolean(checked))} /> +