From fac40deb0ecdc43ca62ddb9372bcc6860af6608a Mon Sep 17 00:00:00 2001
From: JamesOBrien2
Date: Fri, 23 Oct 2026 15:38:07 +0100
Subject: [PATCH 1/5] feat(project-actions): add agent prompt actions
Project actions can now be prompts as well as shell scripts. A prompt runs through the normal message path in the invoking thread, queues behind active work, and can switch the thread to a saved model and reasoning selection first.
Prompt actions persist through server settings and t3.json, appear in the web and mobile action menus, and cannot claim setup or settle lifecycle roles. New threads ask for a first message before a prompt action can run.
---
.../features/threads/DraftThreadHeader.tsx | 1 +
.../features/threads/ThreadGitControls.tsx | 11 +-
.../features/threads/ThreadRouteScreen.tsx | 24 +-
.../orchestration-v2/CommandPolicy.test.ts | 23 ++
apps/server/src/serverSettings.test.ts | 82 ++++++
apps/web/src/components/ChatView.tsx | 10 +-
.../src/components/ProjectScriptsControl.tsx | 2 +-
.../components/chat/useChatTerminalActions.ts | 60 +++-
.../projectScriptEditor.browser.test.tsx | 39 +++
.../src/components/projectScriptEditor.tsx | 268 ++++++++++++++----
.../settings/ProjectActionsList.tsx | 13 +-
.../settings/ProjectActionsSettings.tsx | 2 +-
apps/web/src/projectScripts.test.ts | 50 ++++
apps/web/src/projectScripts.ts | 18 +-
docs/user/keybindings.md | 2 +-
docs/user/project-settings.md | 8 +
.../src/operations/commands.test.ts | 172 +++++++++++
.../client-runtime/src/operations/commands.ts | 46 ++-
.../src/state/threadCommands.ts | 8 +
packages/contracts/src/project.test.ts | 45 +++
packages/contracts/src/project.ts | 23 +-
packages/shared/src/projectScripts.ts | 17 +-
22 files changed, 840 insertions(+), 84 deletions(-)
diff --git a/apps/mobile/src/features/threads/DraftThreadHeader.tsx b/apps/mobile/src/features/threads/DraftThreadHeader.tsx
index 509545b58c8bb..429a50fe63d9f 100644
--- a/apps/mobile/src/features/threads/DraftThreadHeader.tsx
+++ b/apps/mobile/src/features/threads/DraftThreadHeader.tsx
@@ -71,6 +71,7 @@ export function DraftThreadHeader(props: {
canOperateTerminal: false,
canOpenFiles: canReadFiles && props.cwd !== null,
projectScripts: [],
+ canRunPrompt: false,
terminalSessions: [],
onOpenFilesInspector: openFiles,
onOpenGitInspector: requiresThread,
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index e49f4caadf8fa..c731fc408cb4f 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -103,6 +103,7 @@ type ThreadGitControlsProps = ThreadGitMenuProps & {
};
readonly canOpenTerminal: boolean;
readonly canOperateTerminal: boolean;
+ readonly canRunPrompt: boolean;
readonly canOpenFiles: boolean;
readonly projectScripts: ReadonlyArray;
readonly terminalSessions: ReadonlyArray;
@@ -281,8 +282,8 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
menu: {
items: [
...props.projectScripts.map((script) => ({
- description: script.command,
- disabled: !props.canOperateTerminal,
+ description: script.type === "prompt" ? script.prompt : script.command,
+ disabled: script.type === "prompt" ? !props.canRunPrompt : !props.canOperateTerminal,
icon: { name: projectScriptMenuIcon(script.icon), type: "sfSymbol" as const },
label: projectScriptMenuLabel(script),
onPress: () => void props.onRunProjectScript(script),
@@ -450,9 +451,11 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
void props.onRunProjectScript(script)}
- subtitle={script.command}
+ subtitle={script.type === "prompt" ? script.prompt : script.command}
>
{projectScriptMenuLabel(script)}
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index eedfa6e1d8210..2427a319a171f 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -637,7 +637,27 @@ function ThreadRouteContent(
});
};
+ const runPrompt = useAtomCommand(threadEnvironment.runProjectPrompt, "run prompt action");
const handleRunProjectScript = async (script: ProjectScript) => {
+ if (script.type === "prompt") {
+ if (
+ !selectedThread ||
+ !readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope)
+ )
+ return;
+ await runPrompt({
+ environmentId: selectedThread.environmentId,
+ input: {
+ threadId: selectedThread.id,
+ action: script,
+ creationSource: "mobile",
+ ...(composer.modelSelection ? { modelSelection: composer.modelSelection } : {}),
+ ...(composer.runtimeMode ? { runtimeMode: composer.runtimeMode } : {}),
+ ...(composer.interactionMode ? { interactionMode: composer.interactionMode } : {}),
+ },
+ });
+ return;
+ }
terminalDebugLog("project-script:press", {
scriptId: script.id,
command: script.command,
@@ -726,8 +746,10 @@ function ThreadRouteContent(
gitStatus: gitStatus.data,
gitOperationLabel: gitState.gitOperationLabel,
canOpenTerminal:
- Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
+ Boolean(selectedThreadProject?.workspaceRoot) &&
+ (canReadTerminal || canOperateTerminal || canOperateThread),
canOperateTerminal,
+ canRunPrompt: canOperateThread,
canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
projectScripts: selectedThreadProject
? resolveProjectScripts(
diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts
index 1bf294641a01c..719945b4a306b 100644
--- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts
+++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts
@@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";
import {
CommandId,
MessageId,
+ type ModelSelection,
type OrchestrationV2ProviderCapabilities,
type OrchestrationV2ThreadProjection,
ProviderInstanceId,
@@ -200,6 +201,28 @@ it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) =
const layer = it.layer(CommandPolicy.layer);
layer("CommandPolicyV2", (it) => {
+ it.effect("starts an idle queued prompt with the thread's arbitrary model and options", () =>
+ Effect.gen(function* () {
+ const policy = yield* CommandPolicy.CommandPolicyV2;
+ const modelSelection: ModelSelection = {
+ instanceId: ProviderInstanceId.make("custom-runtime"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ };
+ const projection = {
+ ...dispatchProjection(),
+ thread: { id: threadId, modelSelection },
+ } as OrchestrationV2ThreadProjection;
+ const decision = yield* policy.decideMessageDispatch({
+ commandId,
+ projection,
+ requestedMode: { type: "queue_after_active" },
+ capabilities: baseCapabilities,
+ });
+ assert.deepEqual(decision, { type: "start_run", modelSelection });
+ }),
+ );
+
it.effect("falls back to portable context when Cursor cannot fork natively", () =>
Effect.gen(function* () {
const policy = yield* CommandPolicy.CommandPolicyV2;
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index ac4e0d394284a..8b9832e62bdde 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -1722,6 +1722,88 @@ it.layer(NodeServices.layer)("server settings", (it) => {
},
);
+ it.effect(
+ "persists prompt actions beside legacy shells and preserves unrelated settings on later saves",
+ () =>
+ Effect.gen(function* () {
+ const service = yield* ServerSettingsModule.ServerSettingsService;
+ const config = yield* ServerConfig.ServerConfig;
+ const fs = yield* FileSystem.FileSystem;
+ const projectId = ProjectId.make("prompt-project");
+ const shell: ProjectScript = {
+ id: "test",
+ name: "Test",
+ command: "npm test",
+ icon: "test",
+ runOnWorktreeCreate: true,
+ async: false,
+ runOnSettle: true,
+ };
+ const prompt: ProjectScript = {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Read project instructions.\nReport completed results without changes.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ };
+ yield* service.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: { defaultAutoPull: true, defaultProjectScripts: [shell, prompt] },
+ },
+ });
+ const saved = yield* decodeServerSettingsJson(
+ yield* fs.readFileString(config.settingsPath),
+ );
+ assert.deepEqual(saved.projectSettingsOverrides[projectId], {
+ defaultAutoPull: true,
+ defaultProjectScripts: [shell, prompt],
+ });
+ const reloaded = yield* Effect.gen(function* () {
+ const fresh = yield* ServerSettingsModule.ServerSettingsService;
+ return yield* fresh.getSettings;
+ }).pipe(
+ Effect.provide(
+ Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ServerSecretStore.layer)),
+ ),
+ );
+ assert.deepEqual(
+ reloaded.projectSettingsOverrides[projectId],
+ saved.projectSettingsOverrides[projectId],
+ );
+ yield* service.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: {
+ ...saved.projectSettingsOverrides[projectId],
+ defaultProjectScripts: [shell, { ...prompt, prompt: "Inspect new results." }],
+ },
+ },
+ });
+ const edited = yield* decodeServerSettingsJson(
+ yield* fs.readFileString(config.settingsPath),
+ );
+ assert.deepEqual(edited.projectSettingsOverrides[projectId]?.defaultProjectScripts, [
+ shell,
+ { ...prompt, prompt: "Inspect new results." },
+ ]);
+ yield* service.updateSettings({
+ projectSettingsOverrides: {
+ [projectId]: {
+ ...edited.projectSettingsOverrides[projectId],
+ defaultProjectScripts: [shell],
+ },
+ },
+ });
+ const deleted = yield* decodeServerSettingsJson(
+ yield* fs.readFileString(config.settingsPath),
+ );
+ assert.deepEqual(deleted.projectSettingsOverrides[projectId], {
+ defaultAutoPull: true,
+ defaultProjectScripts: [shell],
+ });
+ }).pipe(Effect.provide(layerServerSettings())),
+ );
+
it.effect("folds legacy project overrides into projectSettingsOverrides once", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 92707af8ca119..f6a48957c18d9 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1859,8 +1859,12 @@ export default function ChatView(props: ChatViewProps) {
composerRef,
terminalUiState,
activeThread,
+ modelSelection: composerSelectedModelSelection ?? activeThread?.modelSelection,
+ runtimeMode,
+ interactionMode,
activeThreadId,
activeThreadRef,
+ isServerThread,
activeProject,
activeRightPanelSurface,
runningTerminalIds,
@@ -2745,9 +2749,11 @@ export default function ChatView(props: ChatViewProps) {
},
fallback: (command) => {
const scriptId = projectScriptIdFromCommand(command);
- if (!scriptId || !activeProject || !canOperateTerminal) return undefined;
+ if (!scriptId || !activeProject) return undefined;
const script = activeProjectScripts.find((entry) => entry.id === scriptId);
- return script ? () => void runProjectScriptStable(script) : undefined;
+ if (!script || (script.type === "prompt" ? !canOperateThread : !canOperateTerminal))
+ return undefined;
+ return () => void runProjectScriptStable(script);
},
});
diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx
index b22da09463562..5d9ab2a34ac93 100644
--- a/apps/web/src/components/ProjectScriptsControl.tsx
+++ b/apps/web/src/components/ProjectScriptsControl.tsx
@@ -111,7 +111,7 @@ export default function ProjectScriptsControl({
(fileScript) =>
!scripts.some(
(script) =>
- script.command === fileScript.command ||
+ (script.type !== "prompt" && script.command === fileScript.command) ||
script.name.toLowerCase() === fileScript.name.toLowerCase(),
),
);
diff --git a/apps/web/src/components/chat/useChatTerminalActions.ts b/apps/web/src/components/chat/useChatTerminalActions.ts
index 02b6fcd9851c8..b30a14a195e90 100644
--- a/apps/web/src/components/chat/useChatTerminalActions.ts
+++ b/apps/web/src/components/chat/useChatTerminalActions.ts
@@ -9,6 +9,7 @@ import { useStableCallback } from "../../hooks/useStableCallback";
import { useThreadTerminalActions } from "../terminal/useThreadTerminalActions";
import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
import {
+ AuthOrchestrationOperateScope,
AuthTerminalReadScope,
AuthTerminalOperateScope,
type EnvironmentId,
@@ -38,8 +39,10 @@ import {
makeThreadErrorSetter,
} from "../ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
+import { useComposerDraftStore } from "../../composerDraftStore";
import { previewEnvironment } from "../../state/preview";
import { readEnvironmentScope } from "../../state/session";
+import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import type { ChatShortcutCommands } from "./useChatShortcuts";
const SCRIPT_TERMINAL_COLS = 120;
@@ -56,8 +59,12 @@ interface ChatTerminalActionsInput {
readonly composerRef: ComposerHandleRef;
readonly terminalUiState: ReturnType;
readonly activeThread: EnvironmentThreadShell | undefined;
+ readonly modelSelection: EnvironmentThreadShell["modelSelection"] | undefined;
+ readonly runtimeMode: EnvironmentThreadShell["runtimeMode"] | undefined;
+ readonly interactionMode: EnvironmentThreadShell["interactionMode"] | undefined;
readonly activeThreadId: ThreadId | null;
readonly activeThreadRef: ScopedThreadRef | null;
+ readonly isServerThread: boolean;
readonly activeProject: EnvironmentProject | null;
readonly activeRightPanelSurface: RightPanelSurface | null;
readonly runningTerminalIds: ReadonlyArray;
@@ -81,8 +88,12 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) {
composerRef,
terminalUiState,
activeThread,
+ modelSelection,
+ runtimeMode,
+ interactionMode,
activeThreadId,
activeThreadRef,
+ isServerThread,
activeProject,
activeRightPanelSurface,
runningTerminalIds,
@@ -95,6 +106,8 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) {
setTerminalUiLaunchContext,
setThreadError,
} = input;
+ const runPrompt = useAtomCommand(threadEnvironment.runProjectPrompt, { reportFailure: false });
+ const setComposerModelSelection = useComposerDraftStore((store) => store.setModelSelection);
const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
const hasTerminalWriteAccess = () =>
@@ -199,13 +212,58 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) {
shellHistory?: "preserve" | "suppress";
},
) => {
- if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
+ if (!activeThreadId || !activeProject || !activeThread) return;
+ if (script.type !== "prompt" && !hasTerminalWriteAccess()) return;
if (options?.rememberAsLastInvoked !== false) {
setLastInvokedScriptByProjectId((current) => {
if (current[activeProject.id] === script.id) return current;
return { ...current, [activeProject.id]: script.id };
});
}
+ if (script.type === "prompt") {
+ if (!isServerThread) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "warning",
+ title: "Start this thread first",
+ description: "Send a message before running a prompt action.",
+ }),
+ );
+ return;
+ }
+ if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) {
+ setThreadError(activeThreadId, "This connection cannot run prompt actions.");
+ return;
+ }
+ const result = await runPrompt({
+ environmentId,
+ input: {
+ threadId: activeThreadId,
+ action: script,
+ ...(modelSelection ? { modelSelection } : {}),
+ ...(runtimeMode ? { runtimeMode } : {}),
+ ...(interactionMode ? { interactionMode } : {}),
+ },
+ });
+ if (result._tag === "Failure") {
+ if (!isAtomCommandInterrupted(result)) {
+ const error = squashAtomCommandFailure(result);
+ setThreadError(
+ activeThreadId,
+ error instanceof Error
+ ? error.message
+ : `Could not run prompt action "${script.name}". Check that the thread's model and provider are available.`,
+ );
+ }
+ return;
+ }
+ if (script.modelSelection && activeThreadRef) {
+ setComposerModelSelection(activeThreadRef, script.modelSelection, {
+ replaceOptions: true,
+ });
+ }
+ return;
+ }
const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot;
const reusableTerminalId =
options?.preferNewTerminal ||
diff --git a/apps/web/src/components/projectScriptEditor.browser.test.tsx b/apps/web/src/components/projectScriptEditor.browser.test.tsx
index a9ea6f03ad185..cba857a7c86c9 100644
--- a/apps/web/src/components/projectScriptEditor.browser.test.tsx
+++ b/apps/web/src/components/projectScriptEditor.browser.test.tsx
@@ -1,6 +1,7 @@
import { act, render, type Screen } from "../../test/render";
import { EnvironmentId } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
+import { page, userEvent } from "vite-plus/test/browser";
import { StrictMode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
@@ -86,3 +87,41 @@ describe("project action editor save lifecycle", () => {
expect(onSubmit.mock.calls.map(([scriptId]) => scriptId)).toEqual(["build", "test"]);
});
});
+
+it("switches from shell to prompt, records a shortcut, and saves the prompt editor", async () => {
+ setup();
+ onSubmit.mockResolvedValue(AsyncResult.success(undefined));
+ await using screen = await render(null);
+ await open(screen, {
+ scriptId: null,
+ initial: { ...EMPTY_PROJECT_SCRIPT_INPUT, name: "Research status" },
+ });
+ await expect.element(page.getByLabelText("Command", { exact: true })).toBeVisible();
+ await userEvent.click(page.getByRole("combobox", { name: "Action type" }));
+ await userEvent.click(page.getByRole("option", { name: "Prompt", exact: true }));
+ await expect.element(page.getByLabelText("Command", { exact: true })).not.toBeInTheDocument();
+ await expect
+ .element(page.getByText("Run automatically on worktree creation"))
+ .not.toBeInTheDocument();
+ await userEvent.type(
+ page.getByLabelText("Prompt", { exact: true }),
+ "Read instructions. Report results without edits.",
+ );
+ await userEvent.click(page.getByLabelText("Keybinding", { exact: true }));
+ await userEvent.keyboard("{Control>}{Shift>}p{/Shift}{/Control}");
+ await expect
+ .element(page.getByLabelText("Keybinding", { exact: true }))
+ .toHaveValue("ctrl+shift+p");
+ await act(async () => submit());
+ await vi.waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ null,
+ expect.objectContaining({
+ type: "prompt",
+ prompt: "Read instructions. Report results without edits.",
+ name: "Research status",
+ keybinding: "ctrl+shift+p",
+ }),
+ ),
+ );
+});
diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx
index b3ea5301be830..ac27ec76c58db 100644
--- a/apps/web/src/components/projectScriptEditor.tsx
+++ b/apps/web/src/components/projectScriptEditor.tsx
@@ -2,6 +2,7 @@ import {
AuthOrchestrationOperateScope,
AuthSettingsWriteScope,
type EnvironmentId,
+ type ModelSelection,
type ProjectScript,
type ProjectScriptIcon,
type ResolvedKeybindingsConfig,
@@ -11,6 +12,8 @@ import {
squashAtomCommandFailure,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
+import { createModelSelection } from "@t3tools/shared/model";
+import { useAtomValue } from "@effect/atom-react";
import {
BugIcon,
FlaskConicalIcon,
@@ -33,7 +36,18 @@ import {
} from "~/lib/projectScriptKeybindings";
import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
+import {
+ applyProviderInstanceSettings,
+ deriveProviderInstanceEntries,
+ resolveDefaultProviderModelSelection,
+ sortProviderInstanceEntries,
+} from "~/providerInstances";
+import { getCustomModelOptionsByInstance } from "~/modelSelection";
+import { useEnvironmentSettings } from "~/hooks/useSettings";
+import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "~/state/server";
import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
+import { ProviderModelPicker } from "./chat/ProviderModelPicker";
+import { TraitsPicker } from "./chat/TraitsPicker";
import { useComposerMenuState } from "./chat/useComposerMenuState";
import {
AlertDialog,
@@ -57,6 +71,7 @@ import {
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
+import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select";
import { Switch } from "./ui/switch";
import { Textarea } from "./ui/textarea";
@@ -86,6 +101,9 @@ export function ScriptIcon({
export interface NewProjectScriptInput {
name: string;
+ type?: "shell" | "prompt";
+ prompt?: string;
+ modelSelection?: ModelSelection | null;
command: string;
icon: ProjectScriptIcon;
runOnWorktreeCreate: boolean;
@@ -125,7 +143,10 @@ export function editorRequestForScript(
scriptId: script.id,
initial: {
name: script.name,
- command: script.command,
+ type: script.type ?? "shell",
+ prompt: script.type === "prompt" ? script.prompt : "",
+ modelSelection: script.type === "prompt" ? (script.modelSelection ?? null) : null,
+ command: script.type === "prompt" ? "" : script.command,
icon: script.icon,
runOnWorktreeCreate: script.runOnWorktreeCreate,
waitForSetup: script.runOnWorktreeCreate && script.async === false,
@@ -262,6 +283,8 @@ function ProjectScriptEditorForm({
const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
const formId = React.useId();
const [name, setName] = useState("");
+ const [actionType, setActionType] = useState<"shell" | "prompt">("shell");
+ const [prompt, setPrompt] = useState("");
const [command, setCommand] = useState("");
const [icon, setIcon] = useState("play");
const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);
@@ -271,7 +294,30 @@ function ProjectScriptEditorForm({
const [keybinding, setKeybinding] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [autoOpenPreview, setAutoOpenPreview] = useState(false);
+ const [modelSelection, setModelSelection] = useState(null);
const [savingRequest, setSavingRequest] = useState(null);
+ const settings = useEnvironmentSettings(environmentId);
+ const providers =
+ useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
+ const instanceEntries = sortProviderInstanceEntries(
+ applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, null),
+ );
+ const fallbackModelSelection =
+ resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection) ??
+ instanceEntries.reduce((selection, entry) => {
+ if (selection || !entry.enabled || !entry.models[0]) return selection;
+ return createModelSelection(entry.instanceId, entry.models[0].slug);
+ }, null);
+ const effectiveModelSelection = modelSelection ?? fallbackModelSelection;
+ const activeEntry =
+ instanceEntries.find((entry) => entry.instanceId === effectiveModelSelection?.instanceId) ??
+ null;
+ const modelOptionsByInstance = getCustomModelOptionsByInstance(
+ settings,
+ providers,
+ effectiveModelSelection?.instanceId,
+ effectiveModelSelection?.model,
+ );
const pendingSubmissionRef = useRef<{ request: ProjectScriptEditorRequest } | null>(null);
const isEditing = request?.scriptId != null;
@@ -291,6 +337,8 @@ function ProjectScriptEditorForm({
setFormRequest(request);
if (request) {
setName(request.initial.name);
+ setActionType(request.initial.type ?? "shell");
+ setPrompt(request.initial.prompt ?? "");
setCommand(request.initial.command);
setIcon(request.initial.icon);
setIconPickerOpen(false);
@@ -300,6 +348,7 @@ function ProjectScriptEditorForm({
setKeybinding(request.initial.keybinding ?? "");
setPreviewUrl(request.initial.previewUrl ?? "");
setAutoOpenPreview(request.initial.autoOpenPreview);
+ setModelSelection(request.initial.modelSelection ?? null);
setSavingRequest(null);
}
}
@@ -345,8 +394,8 @@ function ProjectScriptEditorForm({
setValidationError("Name is required.");
return;
}
- if (trimmedCommand.length === 0) {
- setValidationError("Command is required.");
+ if ((actionType === "prompt" ? prompt.trim() : trimmedCommand).length === 0) {
+ setValidationError(actionType === "prompt" ? "Prompt is required." : "Command is required.");
return;
}
@@ -365,6 +414,9 @@ function ProjectScriptEditorForm({
});
const trimmedPreviewUrl = previewUrl.trim();
payload = {
+ type: actionType,
+ prompt: prompt.trim(),
+ ...(actionType === "prompt" && modelSelection ? { modelSelection } : {}),
name: trimmedName,
command: trimmedCommand,
icon,
@@ -414,7 +466,8 @@ function ProjectScriptEditorForm({
{isEditing ? "Edit Action" : "Add Action"}
- Actions are project-scoped commands you can run from the top bar or keybindings.
+ Actions are project-scoped shell scripts or prompts you can run from the top bar or
+ keybindings.
@@ -487,64 +540,157 @@ function ProjectScriptEditorForm({
-
-
-
-
-
setPreviewUrl(event.target.value)}
- />
-
- Open this URL in the in-app preview when this action runs.
-
+
+
-
-
-
-
+ {actionType === "prompt" ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
setPreviewUrl(event.target.value)}
+ />
+
+ Open this URL in the in-app preview when this action runs.
+
+
+
+
+
+
+ >
+ )}
{validationError && {validationError}
}
diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx
index cc19de17746be..d58c8df852d99 100644
--- a/apps/web/src/components/settings/ProjectActionsList.tsx
+++ b/apps/web/src/components/settings/ProjectActionsList.tsx
@@ -33,6 +33,11 @@ export function ProjectActionsList({
{script.name}
+ {script.type === "prompt" ? (
+
+ prompt
+
+ ) : null}
{script.runOnWorktreeCreate ? (
setup
@@ -50,7 +55,13 @@ export function ProjectActionsList({
) : null}
}
- description={{script.command}}
+ description={
+ script.type === "prompt" ? (
+ {script.prompt}
+ ) : (
+ {script.command}
+ )
+ }
control={
<>
{shortcutLabel ? (
diff --git a/apps/web/src/components/settings/ProjectActionsSettings.tsx b/apps/web/src/components/settings/ProjectActionsSettings.tsx
index e212f2ed98599..768a432cea477 100644
--- a/apps/web/src/components/settings/ProjectActionsSettings.tsx
+++ b/apps/web/src/components/settings/ProjectActionsSettings.tsx
@@ -88,7 +88,7 @@ export function ProjectActionsSettings() {
(fileScript) =>
!scripts.some(
(script) =>
- script.command === fileScript.command ||
+ (script.type !== "prompt" && script.command === fileScript.command) ||
script.name.toLowerCase() === fileScript.name.toLowerCase(),
),
);
diff --git a/apps/web/src/projectScripts.test.ts b/apps/web/src/projectScripts.test.ts
index 7ac5682fb6631..bca3a645ef7c3 100644
--- a/apps/web/src/projectScripts.test.ts
+++ b/apps/web/src/projectScripts.test.ts
@@ -1,8 +1,10 @@
import { shortcutLabelForCommand } from "./keybindings";
import { describe, expect, it } from "vite-plus/test";
+import { ProviderInstanceId } from "@t3tools/contracts";
import { settleProjectScript, setupProjectScript } from "@t3tools/shared/projectScripts";
import {
+ buildProjectScript,
commandForProjectScript,
nextProjectScriptId,
primaryProjectScript,
@@ -90,3 +92,51 @@ describe("projectScripts helpers", () => {
).toBe(holder);
});
});
+
+it("creates and edits prompt actions without shell settings or claiming lifecycle roles", () => {
+ const input = {
+ type: "prompt",
+ prompt: "Read project instructions and report status.",
+ command: "echo old shell",
+ name: "Status",
+ icon: "debug",
+ runOnWorktreeCreate: true,
+ waitForSetup: true,
+ runOnSettle: true,
+ previewUrl: "http://localhost:3000",
+ autoOpenPreview: true,
+ } as const;
+ const action = buildProjectScript("status", input);
+ expect(action).toEqual({
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: input.prompt,
+ icon: "debug",
+ runOnWorktreeCreate: false,
+ });
+ expect(setupProjectScript([action])).toBeNull();
+ expect(settleProjectScript([action])).toBeNull();
+ expect(
+ buildProjectScript(action.id, { ...input, name: "Updated", prompt: "Read new outputs." }),
+ ).toMatchObject({ id: "status", name: "Updated", prompt: "Read new outputs." });
+ const modelSelection = {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "max" as const }],
+ };
+ expect(buildProjectScript("status", { ...input, modelSelection })).toMatchObject({
+ modelSelection,
+ });
+ const shell = buildProjectScript("shell", { ...input, type: "shell" });
+ expect(shell).toMatchObject({
+ command: "echo old shell",
+ runOnWorktreeCreate: true,
+ async: false,
+ runOnSettle: true,
+ previewUrl: input.previewUrl,
+ autoOpenPreview: true,
+ });
+ expect(shell).not.toHaveProperty("prompt");
+ expect(releaseClaimedRoles(shell, input)).toBe(shell);
+});
diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts
index dffdb0337d5c3..22ab6ed16bb0d 100644
--- a/apps/web/src/projectScripts.ts
+++ b/apps/web/src/projectScripts.ts
@@ -2,6 +2,7 @@ import {
MAX_SCRIPT_ID_LENGTH,
SCRIPT_RUN_COMMAND_PATTERN,
type KeybindingCommand,
+ type ModelSelection,
type ProjectScript,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
@@ -9,7 +10,10 @@ const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN);
export interface ProjectScriptInput {
readonly name: ProjectScript["name"];
- readonly command: ProjectScript["command"];
+ readonly type?: "shell" | "prompt";
+ readonly prompt?: string;
+ readonly modelSelection?: ModelSelection | null;
+ readonly command: string;
readonly icon: ProjectScript["icon"];
readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];
readonly waitForSetup: boolean;
@@ -19,6 +23,17 @@ export interface ProjectScriptInput {
}
export function buildProjectScript(id: string, input: ProjectScriptInput): ProjectScript {
+ if (input.type === "prompt") {
+ return {
+ id,
+ name: input.name,
+ type: "prompt",
+ prompt: input.prompt ?? "",
+ ...(input.modelSelection ? { modelSelection: input.modelSelection } : {}),
+ icon: input.icon,
+ runOnWorktreeCreate: false,
+ };
+ }
return {
id,
name: input.name,
@@ -40,6 +55,7 @@ export function releaseClaimedRoles(
script: ProjectScript,
saved: ProjectScriptInput,
): ProjectScript {
+ if (script.type === "prompt" || saved.type === "prompt") return script;
const releaseSetup = saved.runOnWorktreeCreate && script.runOnWorktreeCreate;
const releaseSettle = saved.runOnSettle && script.runOnSettle === true;
if (!releaseSetup && !releaseSettle) return script;
diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md
index 085dee90af363..71a423d4eeafb 100644
--- a/docs/user/keybindings.md
+++ b/docs/user/keybindings.md
@@ -98,7 +98,7 @@ Invalid rules are ignored; if the file cannot be parsed, T3 Code uses defaults.
Each rule requires a `key` shortcut and a `command` ID. An optional `when`
expression restricts when it runs.
-Project scripts use `script.{id}.run`, such as `script.test.run`.
+Project actions, including shell scripts and prompts, use `script.{id}.run`, such as `script.test.run`.
## Key syntax
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index db994a7fe8de7..8fccdaf0d038c 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -156,6 +156,14 @@ checkouts and removal. Actions belong to a project: editing them creates the pro
on each selected environment, and reset returns to the environment's shared list. A project's
`t3.json` actions can be imported there.
+Choose **Shell script** to run a terminal command, or **Prompt** to save a task for the agent.
+Run a prompt from the thread's action menu or its configured keyboard shortcut. By default it
+uses that thread's model, workspace, and permissions, and queues behind any active turn. To give
+an action its own model and reasoning level, turn off **Use the thread's current model** and pick
+one; the thread switches to that selection before the prompt runs. Start the thread with a
+message before running a prompt action from a new thread. Automatic setup and settle hooks apply
+only to shell scripts.
+
Settings a repository can also declare in `t3.json`, such as the workspace for new threads,
resolve in one order: a project override, then the environment setting, then `t3.json`, then the
built-in default. Leave a setting on **Inherit** to let the next tier decide.
diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts
index bcfae19403483..d55ce74328b8f 100644
--- a/packages/client-runtime/src/operations/commands.test.ts
+++ b/packages/client-runtime/src/operations/commands.test.ts
@@ -41,6 +41,7 @@ import {
revertThreadCheckpoint,
resumeThreadQueue,
startThreadTurn,
+ runProjectPrompt,
} from "./commands.ts";
const layerTestCrypto = Layer.succeed(
@@ -693,3 +694,174 @@ describe("V2 environment commands", () => {
}).pipe(Effect.provide(layerTestCrypto)),
);
});
+
+it.effect(
+ "runs a saved project prompt through normal dispatch in the invoking thread and worktree",
+ () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const launches: OrchestrationV2ThreadLaunchInput[] = [];
+ const projection: OrchestrationV2ThreadProjection = {
+ ...v2Projection,
+ thread: {
+ ...v2Projection.thread,
+ worktreePath: "/workspace/research-worktree",
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ },
+ runtimeMode: "full-access",
+ },
+ };
+ const supervisor = yield* makeSupervisor({ commands, projects: [], launches, projection });
+ const action = {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Read project instructions.\nInspect results without edits.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ } as const;
+ const selectedModel = {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "max" }],
+ };
+ yield* runProjectPrompt({
+ commandId: CommandId.make("project-prompt"),
+ threadId: projection.thread.id,
+ action,
+ modelSelection: selectedModel,
+ runtimeMode: "approval-required",
+ interactionMode: "plan",
+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
+ expect(launches).toEqual([]);
+ expect(commands).toHaveLength(4);
+ expect(commands[0]).toMatchObject({
+ type: "thread.model-selection.set",
+ modelSelection: selectedModel,
+ });
+ expect(commands[1]).toMatchObject({
+ type: "thread.runtime-mode.set",
+ runtimeMode: "approval-required",
+ });
+ expect(commands[2]).toMatchObject({
+ type: "thread.interaction-mode.set",
+ interactionMode: "plan",
+ });
+ expect(commands[3]).toMatchObject({
+ type: "message.dispatch",
+ threadId: projection.thread.id,
+ text: action.prompt,
+ attachments: [],
+ createdBy: "user",
+ dispatchMode: { type: "queue_after_active" },
+ });
+ expect(commands[3]).not.toHaveProperty("runtimeMode");
+ expect(commands[3]).not.toHaveProperty("worktreePath");
+ expect(projection.thread.worktreePath).toBe("/workspace/research-worktree");
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
+
+it.effect("prefers a saved prompt action's model over the composer and thread selections", () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const currentModel = {
+ instanceId: ProviderInstanceId.make("custom-provider"),
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ };
+ const actionModel = {
+ instanceId: ProviderInstanceId.make("claudeAgent"),
+ model: "claude-opus-4-1",
+ options: [{ id: "effort", value: "max" }],
+ };
+ const composerModel = {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.1-codex",
+ };
+ const projection: OrchestrationV2ThreadProjection = {
+ ...v2Projection,
+ thread: { ...v2Projection.thread, modelSelection: currentModel },
+ };
+ const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
+ yield* runProjectPrompt({
+ threadId: projection.thread.id,
+ action: {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect results.",
+ modelSelection: actionModel,
+ icon: "play",
+ runOnWorktreeCreate: false,
+ },
+ modelSelection: composerModel,
+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
+ expect(commands[0]).toMatchObject({
+ type: "thread.model-selection.set",
+ modelSelection: actionModel,
+ });
+ expect(commands[1]).toMatchObject({
+ type: "message.dispatch",
+ text: "Inspect results.",
+ modelSelection: actionModel,
+ });
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
+
+it.effect("reports an unavailable environment when running a saved prompt", () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const supervisor = yield* makeSupervisor({ commands, projects: [] });
+ yield* SubscriptionRef.set(supervisor.session, Option.none());
+ const result = yield* runProjectPrompt({
+ threadId: v2ThreadId,
+ action: {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect results.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ },
+ }).pipe(
+ Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ Effect.result,
+ );
+ expect(result._tag).toBe("Failure");
+ expect(commands).toEqual([]);
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
+
+it.effect("keeps persisted thread settings when a prompt has no composer overrides", () =>
+ Effect.gen(function* () {
+ const commands: OrchestrationV2Command[] = [];
+ const projection: OrchestrationV2ThreadProjection = {
+ ...v2Projection,
+ thread: {
+ ...v2Projection.thread,
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.1-codex",
+ options: [{ id: "reasoningEffort", value: "high" }],
+ },
+ },
+ };
+ const supervisor = yield* makeSupervisor({ commands, projects: [], projection });
+ yield* runProjectPrompt({
+ threadId: projection.thread.id,
+ action: {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect results.",
+ icon: "play",
+ runOnWorktreeCreate: false,
+ },
+ }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor));
+ expect(commands).toHaveLength(1);
+ expect(commands[0]).toMatchObject({ type: "message.dispatch", text: "Inspect results." });
+ }).pipe(Effect.provide(layerTestCrypto)),
+);
diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts
index db343d2d0f8f1..5a715d503ad08 100644
--- a/packages/client-runtime/src/operations/commands.ts
+++ b/packages/client-runtime/src/operations/commands.ts
@@ -8,7 +8,7 @@ import {
OrchestrationV2CheckpointUnavailableError,
WS_METHODS,
type ChatAttachment,
- type MessageId,
+ MessageId,
type ModelSelection,
type NodeId,
type OrchestrationV2Command,
@@ -27,7 +27,7 @@ import {
type ThreadEnvMode,
type UploadChatAttachment,
} from "@t3tools/contracts";
-import { modelSelectionCommandType } from "@t3tools/shared/model";
+import { modelSelectionCommandType, modelSelectionsEqual } from "@t3tools/shared/model";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
import * as Crypto from "effect/Crypto";
@@ -770,6 +770,48 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(
});
});
+export interface RunProjectPromptInput extends ThreadCommandInput {
+ readonly action: Extract;
+ readonly modelSelection?: ModelSelection;
+ readonly runtimeMode?: RuntimeMode;
+ readonly interactionMode?: ProviderInteractionMode;
+}
+
+export const runProjectPrompt = Effect.fn("EnvironmentCommands.runProjectPrompt")(function* (
+ input: RunProjectPromptInput,
+) {
+ const projection = yield* getProjection(input.threadId);
+ const current = projection.thread;
+ const modelSelection =
+ input.action.modelSelection ?? input.modelSelection ?? current.modelSelection;
+ const runtimeMode = input.runtimeMode ?? current.runtimeMode;
+ const interactionMode = input.interactionMode ?? current.interactionMode;
+ if (!modelSelectionsEqual(modelSelection, current.modelSelection)) {
+ yield* updateThreadMetadata({ ...input, modelSelection });
+ }
+ if (runtimeMode !== current.runtimeMode) {
+ yield* setThreadRuntimeMode({ ...input, runtimeMode });
+ }
+ if (interactionMode !== current.interactionMode) {
+ yield* setThreadInteractionMode({ ...input, interactionMode });
+ }
+ const crypto = yield* Crypto.Crypto;
+ return yield* startThreadTurn({
+ ...input,
+ modelSelection,
+ message: {
+ messageId: MessageId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)),
+ role: "user",
+ text: input.action.prompt,
+ attachments: [],
+ },
+ runtimeMode,
+ interactionMode,
+ // Resolve the thread's current selection and approvals on the server, as for a normal send.
+ dispatchMode: "queue",
+ });
+});
+
export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
input: InterruptThreadTurnInput,
) {
diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts
index 0ef8d2d6e1798..7a8558891f0b2 100644
--- a/packages/client-runtime/src/state/threadCommands.ts
+++ b/packages/client-runtime/src/state/threadCommands.ts
@@ -45,6 +45,7 @@ import {
type SettleThreadInput,
type SnoozeThreadInput,
type StartThreadTurnInput,
+ type RunProjectPromptInput,
type StopThreadSessionInput,
type UnarchiveThreadInput,
type UnlinkThreadPullRequestInput,
@@ -81,6 +82,7 @@ import {
settleThread,
snoozeThread,
startThreadTurn,
+ runProjectPrompt,
stopThreadSession,
unarchiveThread,
unlinkThreadPullRequest,
@@ -293,6 +295,12 @@ export function createThreadEnvironmentAtoms(
scheduler,
concurrency,
}),
+ runProjectPrompt: createEnvironmentCommand(runtime, {
+ label: "environment-data:commands:thread:run-project-prompt",
+ execute: (input: RunProjectPromptInput) => runProjectPrompt(input),
+ scheduler,
+ concurrency,
+ }),
startTurn: createEnvironmentCommand(runtime, {
label: "environment-data:commands:thread:start-turn",
execute: (input: StartThreadTurnInput) => startThreadTurn(input),
diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts
index 168886ebe441a..bef91e67a75c1 100644
--- a/packages/contracts/src/project.test.ts
+++ b/packages/contracts/src/project.test.ts
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vite-plus/test";
import { OrchestrationProjectShell } from "./orchestrationProject.ts";
import {
+ ProjectScript,
ProjectFaviconPath,
ReceivedProjectIcon,
StoredProjectIcon,
@@ -177,3 +178,47 @@ effectIt.effect("an icon kind from a newer server shows the default icon", () =>
assert.strictEqual(broken._tag, "Failure");
}),
);
+
+describe("project action persistence", () => {
+ const shell = {
+ id: "test",
+ name: "Test",
+ command: "npm test",
+ icon: "test",
+ runOnWorktreeCreate: true,
+ async: false,
+ previewUrl: "http://localhost:5173",
+ autoOpenPreview: true,
+ };
+ const modelSelection = {
+ instanceId: "custom-provider",
+ model: "deepseek/deepseek-v4.1-flash:free",
+ options: [{ id: "reasoningEffort", value: "max" }],
+ };
+ const prompt = {
+ id: "status",
+ name: "Status",
+ type: "prompt",
+ prompt: "Inspect outputs.\nDo not edit files or submit jobs.",
+ modelSelection,
+ icon: "play",
+ runOnWorktreeCreate: false,
+ };
+
+ it("round trips mixed prompt and legacy shell actions without rewriting shell settings", () => {
+ const json = Schema.fromJsonString(Schema.Array(ProjectScript));
+ const decode = Schema.decodeUnknownSync(json);
+ const actions = decode(JSON.stringify([shell, prompt]));
+ expect(actions).toEqual([shell, prompt]);
+ expect(decode(Schema.encodeSync(json)(actions))).toEqual([shell, prompt]);
+ expect(actions[1]).not.toHaveProperty("command");
+ });
+
+ it("rejects empty prompts and automatic shell lifecycle roles on prompts", () => {
+ const decode = Schema.decodeUnknownSync(ProjectScript);
+ expect(() => decode({ ...prompt, prompt: " " })).toThrow();
+ expect(() => decode({ ...prompt, runOnWorktreeCreate: true })).toThrow();
+ expect(() => decode({ ...prompt, runOnSettle: true })).toThrow();
+ expect(() => decode({ ...prompt, prompt: undefined, command: "echo unsafe" })).toThrow();
+ });
+});
diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts
index 5f71c47f71fc3..e0a6dc5785a97 100644
--- a/packages/contracts/src/project.ts
+++ b/packages/contracts/src/project.ts
@@ -30,17 +30,32 @@ export const ProjectScriptIcon = Schema.Literals([
]);
export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
-export const ProjectScript = Schema.Struct({
+const ProjectScriptFields = {
id: TrimmedNonEmptyString,
name: TrimmedNonEmptyString,
- command: TrimmedNonEmptyString,
icon: ProjectScriptIcon,
- runOnWorktreeCreate: Schema.Boolean,
runOnSettle: Schema.optional(Schema.Boolean),
async: Schema.optional(Schema.Boolean),
previewUrl: Schema.optional(TrimmedNonEmptyString),
autoOpenPreview: Schema.optional(Schema.Boolean),
-});
+};
+
+export const ProjectScript = Schema.Union([
+ Schema.Struct({
+ ...ProjectScriptFields,
+ type: Schema.optional(Schema.Literal("shell")),
+ command: TrimmedNonEmptyString,
+ runOnWorktreeCreate: Schema.Boolean,
+ }),
+ Schema.Struct({
+ ...ProjectScriptFields,
+ type: Schema.Literal("prompt"),
+ prompt: TrimmedNonEmptyString,
+ modelSelection: Schema.optional(ModelSelection),
+ runOnWorktreeCreate: Schema.Literal(false),
+ runOnSettle: Schema.optional(Schema.Literal(false)),
+ }),
+]);
export type ProjectScript = typeof ProjectScript.Type;
export const ProjectIconColor = Schema.Literals([
diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts
index af43f78c93409..bbf740951e7ad 100644
--- a/packages/shared/src/projectScripts.ts
+++ b/packages/shared/src/projectScripts.ts
@@ -64,20 +64,29 @@ export function projectScriptRuntimeEnv(
return env;
}
-export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
- return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
+export function setupProjectScript(scripts: readonly ProjectScript[]) {
+ return (
+ scripts
+ .filter((script) => script.type !== "prompt")
+ .find((script) => script.runOnWorktreeCreate) ?? null
+ );
}
export function projectScriptMenuLabel(script: ProjectScript): string {
const roles = [
+ ...(script.type === "prompt" ? ["prompt"] : []),
...(script.runOnWorktreeCreate ? ["setup"] : []),
...(script.runOnSettle ? ["on settle"] : []),
];
return roles.length === 0 ? script.name : `${script.name} (${roles.join(", ")})`;
}
-export function settleProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
- return scripts.find((script) => script.runOnSettle === true) ?? null;
+export function settleProjectScript(scripts: readonly ProjectScript[]) {
+ return (
+ scripts
+ .filter((script) => script.type !== "prompt")
+ .find((script) => script.runOnSettle === true) ?? null
+ );
}
/**
From 80ebd3a382a8dfd82a42bc84063dbac3e7d24d7b Mon Sep 17 00:00:00 2001
From: JamesOBrien2
Date: Fri, 23 Oct 2026 16:17:25 +0100
Subject: [PATCH 2/5] fix(mobile): sync composer model after prompt action
A prompt action pinned to another model switched the thread but left the composer's explicit model override in place. Synchronize the thread's composer draft to the action's selection after a successful dispatch so the next normal message keeps the action's model.
---
apps/mobile/src/features/threads/ThreadRouteScreen.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 2427a319a171f..8205c2516a5b3 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -75,6 +75,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s
import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useThreadComposerState } from "../../state/use-thread-composer-state";
+import { updateComposerDraftSettings } from "../../state/use-composer-drafts";
import { resolveMergeBackTargetThreadId } from "@t3tools/client-runtime/state/thread-relationships";
import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows";
import { threadEnvironment } from "../../state/threads";
@@ -645,7 +646,7 @@ function ThreadRouteContent(
!readEnvironmentScope(selectedThread.environmentId, AuthOrchestrationOperateScope)
)
return;
- await runPrompt({
+ const result = await runPrompt({
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
@@ -656,6 +657,12 @@ function ThreadRouteContent(
...(composer.interactionMode ? { interactionMode: composer.interactionMode } : {}),
},
});
+ if (result._tag === "Success" && script.modelSelection) {
+ updateComposerDraftSettings(
+ scopedThreadKey(selectedThread.environmentId, selectedThread.id),
+ { modelSelection: script.modelSelection },
+ );
+ }
return;
}
terminalDebugLog("project-script:press", {
From 7f09eee8e5c6cdf236808feb82de214176830ec0 Mon Sep 17 00:00:00 2001
From: JamesOBrien2
Date: Fri, 23 Oct 2026 17:00:55 +0100
Subject: [PATCH 3/5] fix(project-actions): address prompt action review
feedback
Resolve project-only provider enablement, persist Ultrathink prompt state, and give project prompt sub-commands distinct IDs.
---
.../src/components/ProjectScriptsControl.tsx | 4 +
.../components/chat/ThreadDetailsPanel.tsx | 1 +
.../projectScriptEditor.browser.test.tsx | 191 ++++++++++++++++--
.../src/components/projectScriptEditor.tsx | 12 +-
.../settings/ProjectActionsSettings.tsx | 1 +
.../src/operations/commands.test.ts | 6 +
.../client-runtime/src/operations/commands.ts | 20 +-
7 files changed, 214 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx
index 5d9ab2a34ac93..c5a614a4d0977 100644
--- a/apps/web/src/components/ProjectScriptsControl.tsx
+++ b/apps/web/src/components/ProjectScriptsControl.tsx
@@ -2,6 +2,7 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl";
import {
AuthSettingsWriteScope,
type EnvironmentId,
+ type ProjectId,
type ProjectScript,
type T3ProjectFileScript,
} from "@t3tools/contracts";
@@ -63,6 +64,7 @@ interface ProjectScriptsControlProps {
presentation?: "toolbar" | "menu";
onRequestMenuClose?: () => void;
environmentId: EnvironmentId;
+ projectId: ProjectId;
scripts: ReadonlyArray;
fileScripts?: ReadonlyArray;
preferredScriptId?: string | null;
@@ -80,6 +82,7 @@ export default function ProjectScriptsControl({
presentation = "toolbar",
onRequestMenuClose,
environmentId,
+ projectId,
scripts,
fileScripts = NO_FILE_SCRIPTS,
preferredScriptId = null,
@@ -451,6 +454,7 @@ export default function ProjectScriptsControl({
({
+ providers: null as unknown as Atom.Writable>,
+ settings: null as unknown as UnifiedSettings,
+}));
+
vi.mock(import("~/state/session"), async (original) => ({
...(await original()),
readEnvironmentScope: () => true,
useEnvironmentScope: () => true,
}));
+vi.mock(import("~/state/server"), async (original) => {
+ const actual = await original();
+ const { Atom } = await import("effect/reactivity");
+ const providers = Atom.make>([]).pipe(Atom.keepAlive);
+ state.providers = providers;
+ return {
+ ...actual,
+ serverEnvironment: {
+ ...actual.serverEnvironment,
+ providersValueAtom: (() => providers) as never,
+ },
+ };
+});
+vi.mock(import("~/hooks/useSettings"), async (original) => ({
+ ...(await original()),
+ useEnvironmentSettings: (() => state.settings) as never,
+}));
+import { appAtomRegistry } from "~/rpc/atomRegistry";
import {
EMPTY_PROJECT_SCRIPT_INPUT,
ProjectScriptEditorDialog,
@@ -30,18 +62,25 @@ function request(name: string, error?: string): ProjectScriptEditorRequest {
};
}
-const open = (screen: Screen, nextRequest: ProjectScriptEditorRequest) =>
+const open = (
+ screen: Screen,
+ nextRequest: ProjectScriptEditorRequest,
+ projectId: ProjectId | null = null,
+) =>
screen.rerender(
-
-
- ,
+
+
+
+
+ ,
);
const submit = () => document.querySelector("form")!.requestSubmit();
@@ -57,15 +96,17 @@ function deferredSave() {
return { promise, resolve, reject };
}
-function setup() {
+async function setup() {
onSubmit.mockReset();
onClose.mockReset();
onDelete.mockReset();
+ state.settings = DEFAULT_UNIFIED_SETTINGS;
+ await act(async () => appAtomRegistry.set(state.providers, []));
}
describe("project action editor save lifecycle", () => {
it("does not close a replacement request or release its in-flight save", async () => {
- setup();
+ await setup();
await using screen = await render(null);
const first = deferredSave();
const second = deferredSave();
@@ -89,7 +130,7 @@ describe("project action editor save lifecycle", () => {
});
it("switches from shell to prompt, records a shortcut, and saves the prompt editor", async () => {
- setup();
+ await setup();
onSubmit.mockResolvedValue(AsyncResult.success(undefined));
await using screen = await render(null);
await open(screen, {
@@ -125,3 +166,121 @@ it("switches from shell to prompt, records a shortcut, and saves the prompt edit
),
);
});
+
+it("shows a provider that only the project enables", async () => {
+ await setup();
+ const projectId = ProjectId.make("project-a");
+ const instanceId = ProviderInstanceId.make("codex_work");
+ const provider: ServerProvider = {
+ instanceId,
+ driver: ProviderDriverKind.make("codex"),
+ displayName: "Codex Work",
+ enabled: false,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [{ slug: "gpt-5", name: "GPT-5", isCustom: false, capabilities: null }],
+ skills: [],
+ slashCommands: [],
+ };
+ state.settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ providerInstances: {
+ [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
+ },
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
+ },
+ };
+ await act(async () => appAtomRegistry.set(state.providers, [provider]));
+ await using screen = await render(null);
+ await open(
+ screen,
+ {
+ scriptId: "status",
+ initial: {
+ ...EMPTY_PROJECT_SCRIPT_INPUT,
+ type: "prompt",
+ prompt: "Report status.",
+ modelSelection: { instanceId, model: "gpt-5" },
+ },
+ },
+ projectId,
+ );
+
+ await userEvent.click(page.getByRole("button", { name: /GPT-5/ }));
+ await expect.element(page.getByRole("button", { name: "Codex Work", exact: true })).toBeVisible();
+});
+
+it("applies and displays an Ultrathink choice in the prompt", async () => {
+ await setup();
+ onSubmit.mockResolvedValue(AsyncResult.success(undefined));
+ const instanceId = ProviderInstanceId.make("claudeAgent");
+ const model = "claude-sonnet-4-5";
+ const provider: ServerProvider = {
+ instanceId,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ displayName: "Claude",
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [
+ {
+ slug: model,
+ name: "Claude Sonnet 4.5",
+ isCustom: false,
+ capabilities: {
+ optionDescriptors: [
+ {
+ id: "effort",
+ label: "Effort",
+ type: "select",
+ currentValue: "high",
+ promptInjectedValues: ["ultrathink"],
+ options: [
+ { id: "low", label: "Low" },
+ { id: "medium", label: "Medium" },
+ { id: "high", label: "High", isDefault: true },
+ { id: "ultrathink", label: "Ultrathink" },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ skills: [],
+ slashCommands: [],
+ };
+ await act(async () => appAtomRegistry.set(state.providers, [provider]));
+ await using screen = await render(null);
+ await open(screen, {
+ scriptId: "review",
+ initial: {
+ ...EMPTY_PROJECT_SCRIPT_INPUT,
+ name: "Review",
+ type: "prompt",
+ prompt: "Review the code.",
+ modelSelection: { instanceId, model, options: [{ id: "effort", value: "high" }] },
+ },
+ });
+
+ await userEvent.click(page.getByRole("button", { name: "High", exact: true }));
+ await userEvent.click(page.getByRole("menuitemradio", { name: /Ultrathink/ }));
+ await expect
+ .element(page.getByLabelText("Prompt", { exact: true }))
+ .toHaveValue("Ultrathink:\nReview the code.");
+ await expect.element(page.getByRole("button", { name: "Ultrathink", exact: true })).toBeVisible();
+
+ await act(async () => submit());
+ await vi.waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ "review",
+ expect.objectContaining({ prompt: "Ultrathink:\nReview the code." }),
+ ),
+ );
+});
diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx
index ac27ec76c58db..83bd5b68821d4 100644
--- a/apps/web/src/components/projectScriptEditor.tsx
+++ b/apps/web/src/components/projectScriptEditor.tsx
@@ -3,6 +3,7 @@ import {
AuthSettingsWriteScope,
type EnvironmentId,
type ModelSelection,
+ type ProjectId,
type ProjectScript,
type ProjectScriptIcon,
type ResolvedKeybindingsConfig,
@@ -47,7 +48,7 @@ import { useEnvironmentSettings } from "~/hooks/useSettings";
import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "~/state/server";
import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
import { ProviderModelPicker } from "./chat/ProviderModelPicker";
-import { TraitsPicker } from "./chat/TraitsPicker";
+import { readPromptUltrathink, TraitsPicker } from "./chat/TraitsPicker";
import { useComposerMenuState } from "./chat/useComposerMenuState";
import {
AlertDialog,
@@ -161,6 +162,7 @@ export function editorRequestForScript(
export function ProjectScriptEditorDialog({
environmentId,
editScope = AuthOrchestrationOperateScope,
+ projectId,
request,
scripts,
onSubmit,
@@ -169,6 +171,7 @@ export function ProjectScriptEditorDialog({
}: {
environmentId: EnvironmentId;
editScope?: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope;
+ projectId: ProjectId | null;
request: ProjectScriptEditorRequest | null;
scripts: ReadonlyArray;
onSubmit: (
@@ -210,6 +213,7 @@ export function ProjectScriptEditorDialog({
;
validationError: string | null;
@@ -300,7 +306,7 @@ function ProjectScriptEditorForm({
const providers =
useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
const instanceEntries = sortProviderInstanceEntries(
- applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, null),
+ applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, projectId),
);
const fallbackModelSelection =
resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection) ??
@@ -606,6 +612,8 @@ function ProjectScriptEditorForm({
instanceId={effectiveModelSelection.instanceId}
models={activeEntry.models}
model={effectiveModelSelection.model}
+ promptUltrathink={readPromptUltrathink(prompt)}
+ onPromptEdit={(edit) => setPrompt((current) => edit(current))}
modelOptions={effectiveModelSelection.options}
planModeEnabled={settings.planModeEnabled}
onModelOptionsChange={(options) =>
diff --git a/apps/web/src/components/settings/ProjectActionsSettings.tsx b/apps/web/src/components/settings/ProjectActionsSettings.tsx
index 768a432cea477..59c194340a101 100644
--- a/apps/web/src/components/settings/ProjectActionsSettings.tsx
+++ b/apps/web/src/components/settings/ProjectActionsSettings.tsx
@@ -203,6 +203,7 @@ export function ProjectActionsSettings() {
command.commandId)).toEqual([
+ "project-prompt:model-selection",
+ "project-prompt:runtime-mode",
+ "project-prompt:interaction-mode",
+ "project-prompt",
+ ]);
expect(commands[0]).toMatchObject({
type: "thread.model-selection.set",
modelSelection: selectedModel,
diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts
index 5a715d503ad08..3e7c9d4f00769 100644
--- a/packages/client-runtime/src/operations/commands.ts
+++ b/packages/client-runtime/src/operations/commands.ts
@@ -786,18 +786,32 @@ export const runProjectPrompt = Effect.fn("EnvironmentCommands.runProjectPrompt"
input.action.modelSelection ?? input.modelSelection ?? current.modelSelection;
const runtimeMode = input.runtimeMode ?? current.runtimeMode;
const interactionMode = input.interactionMode ?? current.interactionMode;
+ const dispatchCommandId = yield* allocateCommandId(input);
if (!modelSelectionsEqual(modelSelection, current.modelSelection)) {
- yield* updateThreadMetadata({ ...input, modelSelection });
+ yield* updateThreadMetadata({
+ commandId: CommandId.make(`${dispatchCommandId}:model-selection`),
+ threadId: input.threadId,
+ modelSelection,
+ });
}
if (runtimeMode !== current.runtimeMode) {
- yield* setThreadRuntimeMode({ ...input, runtimeMode });
+ yield* setThreadRuntimeMode({
+ commandId: CommandId.make(`${dispatchCommandId}:runtime-mode`),
+ threadId: input.threadId,
+ runtimeMode,
+ });
}
if (interactionMode !== current.interactionMode) {
- yield* setThreadInteractionMode({ ...input, interactionMode });
+ yield* setThreadInteractionMode({
+ commandId: CommandId.make(`${dispatchCommandId}:interaction-mode`),
+ threadId: input.threadId,
+ interactionMode,
+ });
}
const crypto = yield* Crypto.Crypto;
return yield* startThreadTurn({
...input,
+ commandId: dispatchCommandId,
modelSelection,
message: {
messageId: MessageId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie)),
From bfe242715fafde65e5da9581e2699df0945f4779 Mon Sep 17 00:00:00 2001
From: JamesOBrien2
Date: Fri, 23 Oct 2026 17:34:02 +0100
Subject: [PATCH 4/5] fix(project-actions): address follow-up review findings
Use project-aware provider entries for the prompt action fallback model and keep the mobile terminal menu available to prompt-only users without enabling terminal sessions.
---
.../features/threads/ThreadGitControls.tsx | 5 +-
.../features/threads/ThreadRouteScreen.tsx | 3 +-
.../projectScriptEditor.browser.test.tsx | 82 +++++++++++++++++++
.../src/components/projectScriptEditor.tsx | 7 +-
apps/web/src/providerInstances.ts | 35 +++++---
5 files changed, 115 insertions(+), 17 deletions(-)
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index c731fc408cb4f..2b35aba92a967 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -275,7 +275,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
return {
terminal: {
accessibilityLabel: "Open terminal",
- disabled: !props.canOpenTerminal,
+ disabled: !props.canOpenTerminal && !props.canRunPrompt,
icon: { name: "terminal", type: "sfSymbol" },
identifier: "thread-right-terminal",
label: "Terminal",
@@ -302,6 +302,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit
]
: []),
...props.terminalSessions.map((session) => ({
+ disabled: !props.canOpenTerminal,
description: [
getTerminalStatusLabel({
status: session.status,
@@ -443,7 +444,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
{props.projectScripts.length > 0 ? (
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index 8205c2516a5b3..3cbbfb120ac80 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -753,8 +753,7 @@ function ThreadRouteContent(
gitStatus: gitStatus.data,
gitOperationLabel: gitState.gitOperationLabel,
canOpenTerminal:
- Boolean(selectedThreadProject?.workspaceRoot) &&
- (canReadTerminal || canOperateTerminal || canOperateThread),
+ Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal),
canOperateTerminal,
canRunPrompt: canOperateThread,
canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot),
diff --git a/apps/web/src/components/projectScriptEditor.browser.test.tsx b/apps/web/src/components/projectScriptEditor.browser.test.tsx
index c5ca50b2e4e7c..3720e9f87030e 100644
--- a/apps/web/src/components/projectScriptEditor.browser.test.tsx
+++ b/apps/web/src/components/projectScriptEditor.browser.test.tsx
@@ -214,6 +214,88 @@ it("shows a provider that only the project enables", async () => {
await expect.element(page.getByRole("button", { name: "Codex Work", exact: true })).toBeVisible();
});
+it("falls back to a project-enabled model when the environment default is disabled", async () => {
+ await setup();
+ onSubmit.mockResolvedValue(AsyncResult.success(undefined));
+ const projectId = ProjectId.make("project-a");
+ const codexInstanceId = ProviderInstanceId.make("codex");
+ const claudeInstanceId = ProviderInstanceId.make("claudeAgent");
+ const providers: ReadonlyArray = [
+ {
+ instanceId: codexInstanceId,
+ driver: ProviderDriverKind.make("codex"),
+ displayName: "Codex",
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [
+ { slug: "gpt-5", name: "GPT-5", isCustom: false, isDefault: true, capabilities: null },
+ ],
+ skills: [],
+ slashCommands: [],
+ },
+ {
+ instanceId: claudeInstanceId,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ displayName: "Claude",
+ enabled: true,
+ installed: true,
+ version: "1.0.0",
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: "2026-10-23T00:00:00.000Z",
+ models: [
+ {
+ slug: "claude-opus-4-8",
+ name: "Claude Opus 4.8",
+ isCustom: false,
+ isDefault: true,
+ capabilities: null,
+ },
+ ],
+ skills: [],
+ slashCommands: [],
+ },
+ ];
+ state.settings = {
+ ...DEFAULT_UNIFIED_SETTINGS,
+ projectSettingsOverrides: {
+ [projectId]: { providerInstances: { [codexInstanceId]: { enabled: false } } },
+ },
+ };
+ await act(async () => appAtomRegistry.set(state.providers, providers));
+ await using screen = await render(null);
+ await open(
+ screen,
+ {
+ scriptId: "review",
+ initial: {
+ ...EMPTY_PROJECT_SCRIPT_INPUT,
+ name: "Review",
+ type: "prompt",
+ prompt: "Review the code.",
+ },
+ },
+ projectId,
+ );
+
+ await userEvent.click(page.getByText("Use the thread's current model"));
+ await expect.element(page.getByRole("button", { name: /Claude Opus 4.8/ })).toBeVisible();
+
+ await act(async () => submit());
+ await vi.waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ "review",
+ expect.objectContaining({
+ modelSelection: { instanceId: claudeInstanceId, model: "claude-opus-4-8" },
+ }),
+ ),
+ );
+});
+
it("applies and displays an Ultrathink choice in the prompt", async () => {
await setup();
onSubmit.mockResolvedValue(AsyncResult.success(undefined));
diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx
index 83bd5b68821d4..ec1b26d076924 100644
--- a/apps/web/src/components/projectScriptEditor.tsx
+++ b/apps/web/src/components/projectScriptEditor.tsx
@@ -40,7 +40,7 @@ import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
- resolveDefaultProviderModelSelection,
+ resolveDefaultProviderModelSelectionFromEntries,
sortProviderInstanceEntries,
} from "~/providerInstances";
import { getCustomModelOptionsByInstance } from "~/modelSelection";
@@ -309,7 +309,10 @@ function ProjectScriptEditorForm({
applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, projectId),
);
const fallbackModelSelection =
- resolveDefaultProviderModelSelection(providers, settings.defaultModelSelection) ??
+ resolveDefaultProviderModelSelectionFromEntries(
+ instanceEntries,
+ settings.defaultModelSelection,
+ ) ??
instanceEntries.reduce((selection, entry) => {
if (selection || !entry.enabled || !entry.models[0]) return selection;
return createModelSelection(entry.instanceId, entry.models[0].slug);
diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts
index e574b48b4864e..be08d9ca57b40 100644
--- a/apps/web/src/providerInstances.ts
+++ b/apps/web/src/providerInstances.ts
@@ -210,12 +210,7 @@ function getProviderInstanceEntry(
return deriveProviderInstanceEntries(providers).find((entry) => entry.instanceId === instanceId);
}
-export function getDefaultProviderInstanceModel(
- providers: ReadonlyArray,
- instanceId: ProviderInstanceId,
-): string | undefined {
- const entry = getProviderInstanceEntry(providers, instanceId);
- if (!entry) return undefined;
+function getDefaultProviderInstanceEntryModel(entry: ProviderInstanceEntry): string | undefined {
return (
entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ??
entry.models.find((model) => !model.isCustom)?.slug ??
@@ -224,6 +219,14 @@ export function getDefaultProviderInstanceModel(
);
}
+export function getDefaultProviderInstanceModel(
+ providers: ReadonlyArray,
+ instanceId: ProviderInstanceId,
+): string | undefined {
+ const entry = getProviderInstanceEntry(providers, instanceId);
+ return entry ? getDefaultProviderInstanceEntryModel(entry) : undefined;
+}
+
const isSelectableProviderInstanceEntry = (entry: ProviderInstanceEntry): boolean =>
entry.enabled && entry.isAvailable;
@@ -251,13 +254,23 @@ export function resolveSelectableProviderInstance(
return resolveSelectableProviderInstanceEntry(entries, instanceId)?.instanceId;
}
+export function resolveDefaultProviderModelSelectionFromEntries(
+ entries: ReadonlyArray,
+ selection: ModelSelection | null | undefined,
+): ModelSelection | null {
+ const entry = resolveSelectableProviderInstanceEntry(entries, selection?.instanceId);
+ if (!entry) return null;
+ if (selection?.instanceId === entry.instanceId) return selection;
+ const model = getDefaultProviderInstanceEntryModel(entry);
+ return model ? { instanceId: entry.instanceId, model } : null;
+}
+
export function resolveDefaultProviderModelSelection(
providers: ReadonlyArray,
selection: ModelSelection | null | undefined,
): ModelSelection | null {
- const instanceId = resolveSelectableProviderInstance(providers, selection?.instanceId);
- if (instanceId === undefined) return null;
- if (selection?.instanceId === instanceId) return selection;
- const model = getDefaultProviderInstanceModel(providers, instanceId);
- return model ? { instanceId, model } : null;
+ return resolveDefaultProviderModelSelectionFromEntries(
+ deriveProviderInstanceEntries(providers),
+ selection,
+ );
}
From df10aaf9297c590b594762510fd964620c835938 Mon Sep 17 00:00:00 2001
From: JamesOBrien2
Date: Fri, 23 Oct 2026 17:43:47 +0100
Subject: [PATCH 5/5] fix(mobile): disable terminal sessions for prompt-only
users
---
apps/mobile/src/features/threads/ThreadGitControls.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx
index 2b35aba92a967..6af484b10caf9 100644
--- a/apps/mobile/src/features/threads/ThreadGitControls.tsx
+++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx
@@ -475,6 +475,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) {
props.onOpenTerminal(session.terminalId)}
subtitle={[
getTerminalStatusLabel({