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 3aa5a8df67e41..2ba7ffa3c8d77 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -107,6 +107,7 @@ type ThreadGitControlsProps = ThreadGitMenuProps & { }; readonly canOpenTerminal: boolean; readonly canOperateTerminal: boolean; + readonly canRunPrompt: boolean; readonly canOpenFiles: boolean; readonly projectScripts: ReadonlyArray; readonly terminalSessions: ReadonlyArray; @@ -281,15 +282,15 @@ 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", 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), @@ -308,6 +309,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit ] : []), ...props.terminalSessions.map((session) => ({ + disabled: !props.canOpenTerminal, description: [ getTerminalStatusLabel({ status: session.status, @@ -449,7 +451,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { {props.projectScripts.length > 0 ? ( @@ -457,9 +459,11 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { void props.onRunProjectScript(script)} - subtitle={script.command} + subtitle={script.type === "prompt" ? script.prompt : script.command} > {projectScriptMenuLabel(script)} @@ -478,6 +482,7 @@ export function ThreadGitControls(props: ThreadGitControlsProps) { props.onOpenTerminal(session.terminalId)} subtitle={[ getTerminalStatusLabel({ diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 87e57557eba36..447ed6d8b9d0d 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -71,6 +71,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, useThreadFamily } from "../../state/threads"; @@ -692,7 +693,33 @@ 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; + const result = 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 } : {}), + }, + }); + if (result._tag === "Success" && script.modelSelection) { + updateComposerDraftSettings( + scopedThreadKey(selectedThread.environmentId, selectedThread.id), + { modelSelection: script.modelSelection }, + ); + } + return; + } terminalDebugLog("project-script:press", { scriptId: script.id, command: script.command, @@ -783,6 +810,7 @@ function ThreadRouteContent( canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot) && (canReadTerminal || canOperateTerminal), 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 bd985cab203ac..9affcc947909c 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1721,6 +1721,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 72ad50679d687..4ed55cc18cf19 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1823,8 +1823,12 @@ export default function ChatView(props: ChatViewProps) { composerRef, terminalUiState, activeThread, + modelSelection: composerSelectedModelSelection ?? activeThread?.modelSelection, + runtimeMode, + interactionMode, activeThreadId, activeThreadRef, + isServerThread, activeProject, activeRightPanelSurface, runningTerminalIds, @@ -2675,9 +2679,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 165f57fc6dd07..b932791d82459 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, @@ -111,7 +114,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(), ), ); @@ -450,6 +453,7 @@ export default function ProjectScriptsControl({ ; 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; @@ -85,8 +92,12 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { composerRef, terminalUiState, activeThread, + modelSelection, + runtimeMode, + interactionMode, activeThreadId, activeThreadRef, + isServerThread, activeProject, activeRightPanelSurface, runningTerminalIds, @@ -98,6 +109,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 = () => @@ -205,13 +218,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..3720e9f87030e 100644 --- a/apps/web/src/components/projectScriptEditor.browser.test.tsx +++ b/apps/web/src/components/projectScriptEditor.browser.test.tsx @@ -1,15 +1,48 @@ import { act, render, type Screen } from "../../test/render"; -import { EnvironmentId } from "@t3tools/contracts"; -import { AsyncResult } from "effect/reactivity"; +import { + DEFAULT_UNIFIED_SETTINGS, + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type UnifiedSettings, +} from "@t3tools/contracts"; +import { RegistryContext } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { page, userEvent } from "vite-plus/test/browser"; import { StrictMode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; +const state = vi.hoisted(() => ({ + 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, @@ -29,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(); @@ -56,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(); @@ -86,3 +128,241 @@ 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 () => { + await 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", + }), + ), + ); +}); + +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("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)); + 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 7f44194f98354..8bcec0829d5bc 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -2,6 +2,8 @@ import { AuthOrchestrationOperateScope, AuthSettingsWriteScope, type EnvironmentId, + type ModelSelection, + type ProjectId, type ProjectScript, type ProjectScriptIcon, type ResolvedKeybindingsConfig, @@ -11,6 +13,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, @@ -27,7 +31,18 @@ import { } from "~/lib/projectScriptKeybindings"; import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; import { commandForProjectScript, nextProjectScriptId } from "~/projectScripts"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelectionFromEntries, + 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 { readPromptUltrathink, TraitsPicker } from "./chat/TraitsPicker"; import { useComposerMenuState } from "./chat/useComposerMenuState"; import { AlertDialog, @@ -51,6 +66,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"; @@ -80,6 +96,9 @@ export function ScriptIcon({ export interface NewProjectScriptInput { name: string; + type?: "shell" | "prompt"; + prompt?: string; + modelSelection?: ModelSelection | null; command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; @@ -119,7 +138,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, @@ -134,6 +156,7 @@ export function editorRequestForScript( export function ProjectScriptEditorDialog({ environmentId, editScope = AuthOrchestrationOperateScope, + projectId, request, scripts, onSubmit, @@ -142,6 +165,7 @@ export function ProjectScriptEditorDialog({ }: { environmentId: EnvironmentId; editScope?: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope; + projectId: ProjectId | null; request: ProjectScriptEditorRequest | null; scripts: ReadonlyArray; onSubmit: ( @@ -193,6 +217,7 @@ export function ProjectScriptEditorDialog({ key={shown.key} environmentId={environmentId} editScope={editScope} + projectId={projectId} request={shown.request} scripts={scripts} validationError={validationError} @@ -242,6 +267,7 @@ export function ProjectScriptEditorDialog({ function ProjectScriptEditorForm({ environmentId, editScope, + projectId, request, scripts, validationError, @@ -252,6 +278,7 @@ function ProjectScriptEditorForm({ }: { environmentId: EnvironmentId; editScope: typeof AuthOrchestrationOperateScope | typeof AuthSettingsWriteScope; + projectId: ProjectId | null; request: ProjectScriptEditorRequest; scripts: ReadonlyArray; validationError: string | null; @@ -267,6 +294,8 @@ function ProjectScriptEditorForm({ const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope); const formId = React.useId(); const [name, setName] = useState(request.initial.name); + const [actionType, setActionType] = useState<"shell" | "prompt">(request.initial.type ?? "shell"); + const [prompt, setPrompt] = useState(request.initial.prompt ?? ""); const [command, setCommand] = useState(request.initial.command); const [icon, setIcon] = useState(request.initial.icon); const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions); @@ -278,7 +307,35 @@ function ProjectScriptEditorForm({ const [keybinding, setKeybinding] = useState(request.initial.keybinding ?? ""); const [previewUrl, setPreviewUrl] = useState(request.initial.previewUrl ?? ""); const [autoOpenPreview, setAutoOpenPreview] = useState(request.initial.autoOpenPreview); + const [modelSelection, setModelSelection] = useState( + request.initial.modelSelection ?? null, + ); const [isSaving, setIsSaving] = useState(false); + const settings = useEnvironmentSettings(environmentId); + const providers = + useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const instanceEntries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings, projectId), + ); + const fallbackModelSelection = + 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); + }, 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(null); // A save that settles after this form is replaced must not close or error // the request that replaced it. @@ -332,8 +389,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; } @@ -352,6 +409,9 @@ function ProjectScriptEditorForm({ }); const trimmedPreviewUrl = previewUrl.trim(); payload = { + type: actionType, + prompt: prompt.trim(), + ...(actionType === "prompt" && modelSelection ? { modelSelection } : {}), name: trimmedName, command: trimmedCommand, icon, @@ -401,7 +461,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. @@ -474,64 +535,159 @@ function ProjectScriptEditorForm({

- -