diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18def..cca8a251ec1d 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -500,9 +500,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, automaticGitFetchInterval: Duration.seconds(10), + defaultRuntimeMode: "approval-required", }); assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); + assert.equal(next.defaultRuntimeMode, "approval-required"); const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -522,6 +524,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }, automaticGitFetchInterval: 10_000, + defaultRuntimeMode: "approval-required", }); }).pipe(Effect.provide(makeServerSettingsLayer())), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba8..0ae8111228cc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -101,7 +101,6 @@ import { } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, @@ -153,7 +152,10 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; -import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; +import { + buildNewDraftExecutionDefaults, + resolveNewDraftStartFromOrigin, +} from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -1242,7 +1244,8 @@ function ChatViewContent(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) : localDraftError; - const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const runtimeMode = + composerRuntimeMode ?? activeThread?.runtimeMode ?? settings.defaultRuntimeMode; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; @@ -1564,8 +1567,7 @@ function ChatViewContent(props: ChatViewProps) { setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, + ...buildNewDraftExecutionDefaults(settings.defaultRuntimeMode), ...input, }); await navigate({ @@ -1585,6 +1587,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, + settings.defaultRuntimeMode, ], ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d563143..9fdf1ae9866a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -9,6 +9,7 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, + type RuntimeMode, type ScopedThreadRef, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -110,6 +111,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const RUNTIME_MODE_LABELS: Record = { + "approval-required": "Supervised", + "auto-accept-edits": "Auto-accept edits", + "full-access": "Full access", +}; + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -412,6 +419,9 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] : []), + ...(settings.defaultRuntimeMode !== DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode + ? ["Default access mode"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -429,6 +439,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.defaultRuntimeMode, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, @@ -462,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) { automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, + defaultRuntimeMode: DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -797,6 +809,52 @@ export function GeneralSettingsPanel() { /> ) : null} + + updateSettings({ + defaultRuntimeMode: DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode, + }) + } + /> + ) : null + } + control={ + + } + /> + = {}): ChatTh } describe("chatThreadActions", () => { + it("initializes new drafts with the configured default access mode", () => { + expect(buildNewDraftExecutionDefaults("approval-required")).toEqual({ + runtimeMode: "approval-required", + interactionMode: "default", + }); + }); + it("only applies the start-from-origin default to new worktree drafts", () => { expect( resolveNewDraftStartFromOrigin({ diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4f30885610ad..76a2327a6305 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,5 +1,12 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; +import { + DEFAULT_PROVIDER_INTERACTION_MODE, + type EnvironmentId, + type ProjectId, + type ProviderInteractionMode, + type RuntimeMode, + type ScopedProjectRef, +} from "@t3tools/contracts"; import type { DraftThreadEnvMode } from "../composerDraftStore"; interface ThreadContextLike { @@ -42,6 +49,16 @@ export function resolveNewDraftStartFromOrigin(input: { return input.envMode === "worktree" && input.newWorktreesStartFromOrigin; } +export function buildNewDraftExecutionDefaults(defaultRuntimeMode: RuntimeMode): { + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; +} { + return { + runtimeMode: defaultRuntimeMode, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + }; +} + export function resolveThreadActionProjectRef( context: ChatThreadActionContext, ): ScopedProjectRef | null { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca3365..568984b93e81 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; +import { DEFAULT_RUNTIME_MODE } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -14,6 +15,19 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("ServerSettings default runtime mode", () => { + it("defaults new thread access mode to the existing runtime default", () => { + expect(DEFAULT_SERVER_SETTINGS.defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); + expect(decodeServerSettings({}).defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); + }); + + it("accepts runtime mode patches", () => { + const patch = decodeServerSettingsPatch({ defaultRuntimeMode: "approval-required" }); + + expect(patch.defaultRuntimeMode).toBe("approval-required"); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6ccd65533dde..43e13fcc5336 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -4,7 +4,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; -import { ModelSelection } from "./orchestration.ts"; +import { DEFAULT_RUNTIME_MODE, ModelSelection, RuntimeMode } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -374,6 +374,9 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + defaultRuntimeMode: RuntimeMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), + ), newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), @@ -507,6 +510,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), automaticGitFetchInterval: Schema.optionalKey(Schema.DurationFromMillis), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + defaultRuntimeMode: Schema.optionalKey(RuntimeMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch),