From f25091f8af28c0efb2cfdddeabbdc987718d75ff Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Sun, 14 Jun 2026 21:51:11 +0200 Subject: [PATCH 1/3] 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( From 415a60814d8f4b44b8dbeabf50bf286867b1fdb2 Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Sun, 14 Jun 2026 21:51:11 +0200 Subject: [PATCH 2/3] 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 Co-authored-by: codex --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 13 +++-- .../components/settings/SettingsPanels.tsx | 58 +++++++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 19 ++++-- apps/web/src/lib/chatThreadActions.test.ts | 10 +++- apps/web/src/lib/chatThreadActions.ts | 19 +++++- packages/contracts/src/settings.test.ts | 27 ++++++++- packages/contracts/src/settings.ts | 6 +- 8 files changed, 139 insertions(+), 14 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 a1ef90c4309..37863c7d71e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -89,7 +89,6 @@ import { } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, @@ -141,7 +140,10 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useSettings } 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, @@ -1212,7 +1214,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; @@ -1531,8 +1534,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({ @@ -1552,6 +1554,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 5ecf009a08f..9cf3ecb3fd3 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"; @@ -108,6 +109,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={ + + } + /> + settings.newWorktreesStartFromOrigin, ); + const defaultRuntimeMode = useSettings((settings) => settings.defaultRuntimeMode); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -169,7 +173,7 @@ export function useNewThreadHandler() { envMode: initialEnvMode, newWorktreesStartFromOrigin, }), - runtimeMode: DEFAULT_RUNTIME_MODE, + ...buildNewDraftExecutionDefaults(defaultRuntimeMode), }); applyStickyState(draftId); @@ -179,7 +183,14 @@ export function useNewThreadHandler() { }); })(); }, - [newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects], + [ + defaultRuntimeMode, + newWorktreesStartFromOrigin, + getCurrentRouteTarget, + projectGroupingSettings, + router, + projects, + ], ); } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 62e5aa41d43..d2640aa944a 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -2,8 +2,9 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { - resolveThreadActionProjectRef, + buildNewDraftExecutionDefaults, resolveNewDraftStartFromOrigin, + resolveThreadActionProjectRef, startNewLocalThreadFromContext, startNewThreadFromContext, type ChatThreadActionContext, @@ -25,6 +26,13 @@ function createContext(overrides: Partial = {}): 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 63d0289d104..4144e7ef434 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 { @@ -43,6 +50,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 aba97cbe205..412029aa9f9 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 1cb57a98254..76e5829facc 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(false))), 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([])), ), @@ -516,6 +519,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( From 860f0c6d176974e29a607e79756f109594630bf7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 11:24:33 -0700 Subject: [PATCH 3/3] Persist default access mode in server settings Co-authored-by: codex --- .../settings/DesktopClientSettings.test.ts | 1 - apps/server/src/serverSettings.test.ts | 3 ++ apps/web/src/components/ChatView.tsx | 12 ++++++-- apps/web/src/hooks/useHandleNewThread.ts | 20 +++++++++---- apps/web/src/lib/newThreadSettings.test.ts | 29 +++++++++++++++++++ apps/web/src/lib/newThreadSettings.ts | 18 ++++++++++++ packages/contracts/src/settings.test.ts | 21 ++++---------- packages/contracts/src/settings.ts | 8 ++--- 8 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/lib/newThreadSettings.test.ts create mode 100644 apps/web/src/lib/newThreadSettings.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index c4a4071518d..f666e692860 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -15,7 +15,6 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, - defaultRuntimeMode: "full-access", dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index d24f2ee2826..dba70b2c539 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -441,9 +441,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 @@ -463,6 +465,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 37863c7d71e..74262ef3c97 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -144,6 +144,7 @@ import { buildNewDraftExecutionDefaults, resolveNewDraftStartFromOrigin, } from "../lib/chatThreadActions"; +import { getNewThreadRuntimeMode } from "../lib/newThreadSettings"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -175,6 +176,7 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { primaryServerAvailableEditorsAtom, + primaryServerConfigAtom, primaryServerKeybindingsAtom, serverEnvironment, } from "../state/server"; @@ -1030,6 +1032,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); const settings = useSettings(); + const serverSettings = useAtomValue(primaryServerConfigAtom)?.settings ?? null; const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1529,12 +1532,17 @@ function ChatViewContent(props: ChatViewProps) { return activeDraftSession.threadId; } + const defaultRuntimeMode = getNewThreadRuntimeMode(serverSettings); + if (!defaultRuntimeMode) { + return null; + } + const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - ...buildNewDraftExecutionDefaults(settings.defaultRuntimeMode), + ...buildNewDraftExecutionDefaults(defaultRuntimeMode), ...input, }); await navigate({ @@ -1552,9 +1560,9 @@ function ChatViewContent(props: ChatViewProps) { navigate, projectGroupingSettings, routeKind, + serverSettings, setDraftThreadContext, setLogicalProjectDraftThreadId, - settings.defaultRuntimeMode, ], ); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 5cc9d720884..37c513de894 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,6 +4,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { type ScopedProjectRef } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -24,7 +25,9 @@ import { buildNewDraftExecutionDefaults, resolveNewDraftStartFromOrigin, } from "../lib/chatThreadActions"; +import { getNewThreadRuntimeMode } from "../lib/newThreadSettings"; import { resolveThreadRouteTarget } from "../threadRoutes"; +import { primaryServerConfigAtom } from "../state/server"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; @@ -34,7 +37,7 @@ export function useNewThreadHandler() { const newWorktreesStartFromOrigin = useSettings( (settings) => settings.newWorktreesStartFromOrigin, ); - const defaultRuntimeMode = useSettings((settings) => settings.defaultRuntimeMode); + const serverSettings = useAtomValue(primaryServerConfigAtom)?.settings ?? null; const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; @@ -156,11 +159,16 @@ export function useNewThreadHandler() { return Promise.resolve(); } - const draftId = newDraftId(); - const threadId = newThreadId(); - const createdAt = new Date().toISOString(); - const initialEnvMode = options?.envMode ?? "local"; return (async () => { + const defaultRuntimeMode = getNewThreadRuntimeMode(serverSettings); + if (!defaultRuntimeMode) { + return; + } + + const draftId = newDraftId(); + const threadId = newThreadId(); + const createdAt = new Date().toISOString(); + const initialEnvMode = options?.envMode ?? "local"; setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { threadId, createdAt, @@ -184,12 +192,12 @@ export function useNewThreadHandler() { })(); }, [ - defaultRuntimeMode, newWorktreesStartFromOrigin, getCurrentRouteTarget, projectGroupingSettings, router, projects, + serverSettings, ], ); } diff --git a/apps/web/src/lib/newThreadSettings.test.ts b/apps/web/src/lib/newThreadSettings.test.ts new file mode 100644 index 00000000000..c2da3c56969 --- /dev/null +++ b/apps/web/src/lib/newThreadSettings.test.ts @@ -0,0 +1,29 @@ +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { toastManager } from "../components/ui/toast"; +import { getNewThreadRuntimeMode } from "./newThreadSettings"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getNewThreadRuntimeMode", () => { + it("returns the server-configured default runtime mode", () => { + expect( + getNewThreadRuntimeMode({ + ...DEFAULT_SERVER_SETTINGS, + defaultRuntimeMode: "approval-required", + }), + ).toBe("approval-required"); + }); + + it("blocks thread creation until server settings are available", () => { + const addToast = vi.spyOn(toastManager, "add").mockImplementation(() => "toast-1"); + + expect(getNewThreadRuntimeMode(null)).toBeNull(); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Could not create thread" }), + ); + }); +}); diff --git a/apps/web/src/lib/newThreadSettings.ts b/apps/web/src/lib/newThreadSettings.ts new file mode 100644 index 00000000000..d727a8c076a --- /dev/null +++ b/apps/web/src/lib/newThreadSettings.ts @@ -0,0 +1,18 @@ +import type { RuntimeMode, ServerSettings } from "@t3tools/contracts"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; + +export function getNewThreadRuntimeMode(serverSettings: ServerSettings | null): RuntimeMode | null { + if (serverSettings) { + return serverSettings.defaultRuntimeMode; + } + + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not create thread", + description: "Server settings are not available yet. No thread was created.", + }), + ); + return null; +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 412029aa9f9..fc2660a83ee 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -3,29 +3,20 @@ import * as Schema from "effect/Schema"; import { DEFAULT_RUNTIME_MODE } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.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); +import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; + const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); -describe("ClientSettings default runtime mode", () => { +describe("ServerSettings 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); + expect(DEFAULT_SERVER_SETTINGS.defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); + expect(decodeServerSettings({}).defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); }); it("accepts runtime mode patches", () => { - const patch = decodeClientSettingsPatch({ defaultRuntimeMode: "approval-required" }); + const patch = decodeServerSettingsPatch({ defaultRuntimeMode: "approval-required" }); expect(patch.defaultRuntimeMode).toBe("approval-required"); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 76e5829facc..2874226fcb7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -43,9 +43,6 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), 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([])), ), @@ -377,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)), ), @@ -489,6 +489,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), @@ -519,7 +520,6 @@ 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(