From f25091f8af28c0efb2cfdddeabbdc987718d75ff Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Sun, 14 Jun 2026 21:51:11 +0200 Subject: [PATCH] Add configurable default access mode for new threads - Persist client-side default runtime mode in settings - Use it when creating draft threads and new local threads - Surface the setting in the UI and cover it with tests --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 7 ++- .../components/settings/SettingsPanels.tsx | 58 +++++++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 7 ++- apps/web/src/localApi.test.ts | 2 + packages/contracts/src/settings.test.ts | 25 +++++++- packages/contracts/src/settings.ts | 6 +- 7 files changed, 98 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..c4a4071518d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -15,6 +15,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultRuntimeMode: "full-access", dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 82254b70970..2d724264abe 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -77,7 +77,6 @@ import { } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, @@ -1227,7 +1226,8 @@ export default function ChatView(props: ChatViewProps) { ); const isServerThread = routeKind === "server" && serverThread !== undefined; const activeThread = isServerThread ? serverThread : localDraftThread; - 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; @@ -1567,7 +1567,7 @@ export default function ChatView(props: ChatViewProps) { setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: settings.defaultRuntimeMode, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); @@ -1588,6 +1588,7 @@ export default function ChatView(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 76d5d34c355..318d9a038d6 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"; @@ -101,6 +102,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( @@ -417,6 +424,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.defaultRuntimeMode !== DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode + ? ["Default access mode"] + : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), @@ -434,6 +444,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.defaultRuntimeMode, settings.defaultThreadEnvMode, settings.diffIgnoreWhitespace, settings.diffWordWrap, @@ -465,6 +476,7 @@ export function useSettingsRestore(onRestored?: () => void) { enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + defaultRuntimeMode: DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -738,6 +750,52 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + defaultRuntimeMode: DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode, + }) + } + /> + ) : null + } + control={ + + } + /> + selectProjectsAcrossEnvironments(store))); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const defaultRuntimeMode = useSettings((settings) => settings.defaultRuntimeMode); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -125,7 +126,7 @@ function useNewThreadState() { branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: options?.envMode ?? "local", - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: defaultRuntimeMode, }); applyStickyState(draftId); @@ -135,7 +136,7 @@ function useNewThreadState() { }); })(); }, - [getCurrentRouteTarget, projectGroupingSettings, router, projects], + [defaultRuntimeMode, getCurrentRouteTarget, projectGroupingSettings, router, projects], ); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index f81a7259c93..b1caa28d49c 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -663,6 +663,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultRuntimeMode: "full-access" as const, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, @@ -726,6 +727,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultRuntimeMode: "full-access" as const, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 04ee479bcd3..b19062094c6 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,13 +1,36 @@ 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 { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + ClientSettingsPatch, + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("ClientSettings default runtime mode", () => { + it("defaults new thread access mode to the existing runtime default", () => { + expect(DEFAULT_CLIENT_SETTINGS.defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); + expect(decodeClientSettings({}).defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); + }); + + it("accepts runtime mode patches", () => { + const patch = decodeClientSettingsPatch({ defaultRuntimeMode: "approval-required" }); + + expect(patch.defaultRuntimeMode).toBe("approval-required"); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 33781f56c94..2bfc09aa2af 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) ─────────────────────────────── @@ -43,6 +43,9 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + defaultRuntimeMode: RuntimeMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), + ), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -510,6 +513,7 @@ export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + defaultRuntimeMode: Schema.optionalKey(RuntimeMode), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey(