From b225dfaf77a6013991eed83bd34ac448a75d68cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Mar 2026 13:26:48 -0700 Subject: [PATCH 01/30] Make server settings authoritative - Move settings persistence and validation into the server - Route runtime/provider behavior through server settings - Update client and tests to use the unified settings flow --- .../OrchestrationEngineHarness.integration.ts | 7 + apps/server/src/config.ts | 2 + apps/server/src/main.test.ts | 10 + .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderCommandReactor.ts | 39 +-- .../Layers/ProviderRuntimeIngestion.test.ts | 20 +- .../Layers/ProviderRuntimeIngestion.ts | 20 +- apps/server/src/orchestration/decider.ts | 5 - apps/server/src/serverLayers.ts | 4 + apps/server/src/serverSettings.ts | 274 ++++++++++++++++++ apps/server/src/wsServer.ts | 30 +- apps/web/src/appSettings.test.ts | 59 ---- apps/web/src/appSettings.ts | 143 +++------ apps/web/src/clientSettings.ts | 50 ++++ apps/web/src/components/ChatView.browser.tsx | 10 + apps/web/src/components/ChatView.tsx | 12 +- .../components/KeybindingsToast.browser.tsx | 10 + apps/web/src/hooks/useSettings.ts | 175 +++++++++++ apps/web/src/routes/__root.tsx | 3 + apps/web/src/wsNativeApi.ts | 2 + packages/contracts/src/ipc.ts | 4 +- packages/contracts/src/orchestration.ts | 4 - packages/contracts/src/server.ts | 36 ++- packages/contracts/src/ws.ts | 6 +- 24 files changed, 696 insertions(+), 231 deletions(-) create mode 100644 apps/server/src/serverSettings.ts create mode 100644 apps/web/src/clientSettings.ts create mode 100644 apps/web/src/hooks/useSettings.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f540685b79..04230ef3f9 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -67,6 +67,7 @@ import { type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; +import { ServerSettingsLive } from "../src/serverSettings.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -295,8 +296,13 @@ export const makeOrchestrationIntegrationHarness = ( providerLayer, RuntimeReceiptBusLive, ); + const serverSettingsLayer = ServerSettingsLive.pipe( + Layer.provide(ServerConfig.layerTest(workspaceDir, rootDir)), + Layer.provide(NodeServices.layer), + ); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(serverSettingsLayer), ); const gitCoreLayer = Layer.succeed(GitCore, { renameBranch: (input: Parameters[0]) => @@ -309,6 +315,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitCoreLayer), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(serverSettingsLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 8553ce9667..29f82bccf1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -19,6 +19,7 @@ export interface ServerDerivedPaths { readonly stateDir: string; readonly dbPath: string; readonly keybindingsConfigPath: string; + readonly settingsPath: string; readonly worktreesDir: string; readonly attachmentsDir: string; readonly logsDir: string; @@ -60,6 +61,7 @@ export const deriveServerPaths = Effect.fn(function* ( stateDir, dbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), + settingsPath: join(stateDir, "settings.json"), worktreesDir: join(baseDir, "worktrees"), attachmentsDir, logsDir, diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 369a66c088..0c2c384ef9 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -6,6 +6,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; import * as Command from "effect/unstable/cli/Command"; import { FetchHttpClient } from "effect/unstable/http"; import { beforeEach } from "vitest"; @@ -16,7 +17,9 @@ import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import { Server, type ServerShape } from "./wsServer"; +import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); @@ -52,6 +55,13 @@ const testLayer = Layer.mergeAll( openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, } satisfies OpenShape), + Layer.succeed(ServerSettingsService, { + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + streamChanges: Stream.empty, + } satisfies ServerSettingsShape), AnalyticsService.layerTest, FetchHttpClient.layer, NodeServices.layer, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b58c2522cb..f19476abe8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -17,6 +17,7 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; +import { ServerSettingsLive } from "../../serverSettings.ts"; import { TextGenerationError } from "../../git/Errors.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -214,6 +215,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), + Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 737ad665d7..082c4e54a0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -6,7 +6,6 @@ import { type ModelSelection, type OrchestrationEvent, ProviderKind, - type ProviderStartOptions, type OrchestrationSession, ThreadId, type ProviderSession, @@ -26,6 +25,7 @@ import { ProviderCommandReactor, type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; +import { ServerSettingsService, deriveProviderStartOptions } from "../../serverSettings.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -138,6 +138,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const git = yield* GitCore; const textGeneration = yield* TextGeneration; + const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -151,7 +152,6 @@ const make = Effect.gen(function* () { ), ); - const threadProviderOptions = new Map(); const threadModelSelections = new Map(); const appendProviderFailureActivity = (input: { @@ -210,7 +210,6 @@ const make = Effect.gen(function* () { createdAt: string, options?: { readonly modelSelection?: ModelSelection; - readonly providerOptions?: ProviderStartOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -253,16 +252,18 @@ const make = Effect.gen(function* () { readonly resumeCursor?: unknown; readonly provider?: ProviderKind; }) => - providerService.startSession(threadId, { - threadId, - ...(preferredProvider ? { provider: preferredProvider } : {}), - ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - modelSelection: desiredModelSelection, - ...(options?.providerOptions !== undefined - ? { providerOptions: options.providerOptions } - : {}), - ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - runtimeMode: desiredRuntimeMode, + Effect.gen(function* () { + const serverSettings = yield* serverSettingsService.getSettings; + const providerOptions = deriveProviderStartOptions(serverSettings); + return yield* providerService.startSession(threadId, { + threadId, + ...(preferredProvider ? { provider: preferredProvider } : {}), + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + modelSelection: desiredModelSelection, + ...(providerOptions !== undefined ? { providerOptions } : {}), + ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + runtimeMode: desiredRuntimeMode, + }); }); const bindSessionToThread = (session: ProviderSession) => @@ -354,7 +355,6 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; - readonly providerOptions?: ProviderStartOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -364,11 +364,7 @@ const make = Effect.gen(function* () { } yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), }); - if (input.providerOptions !== undefined) { - threadProviderOptions.set(input.threadId, input.providerOptions); - } if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } @@ -518,9 +514,6 @@ const make = Effect.gen(function* () { ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), - ...(event.payload.providerOptions !== undefined - ? { providerOptions: event.payload.providerOptions } - : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( @@ -686,12 +679,8 @@ const make = Effect.gen(function* () { if (!thread?.session || thread.session.status === "stopped") { return; } - const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); const cachedModelSelection = threadModelSelections.get(event.payload.threadId); yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { - ...(cachedProviderOptions !== undefined - ? { providerOptions: cachedProviderOptions } - : {}), ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), }); return; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b29df5c8fe..dc34c8a2aa 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -12,10 +12,12 @@ import { ApprovalRequestId, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_SERVER_SETTINGS, EventId, MessageId, ProjectId, ProviderItemId, + type ServerSettings, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -38,8 +40,20 @@ import { } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService, type ServerSettingsShape } from "../../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +function makeTestServerSettingsLayer(overrides: Partial = {}) { + const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, ...overrides }; + return Layer.succeed(ServerSettingsService, { + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed(settings), + updateSettings: () => Effect.succeed(settings), + streamChanges: Stream.empty, + } satisfies ServerSettingsShape); +} + const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); @@ -155,7 +169,7 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness() { + async function createHarness(options?: { serverSettings?: Partial }) { const workspaceRoot = makeTempDir("t3-provider-project-"); fs.mkdirSync(path.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -169,6 +183,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); @@ -1357,7 +1372,7 @@ describe("ProviderRuntimeIngestion", () => { }); it("streams assistant deltas when thread.turn.start requests streaming mode", async () => { - const harness = await createHarness(); + const harness = await createHarness({ serverSettings: { enableAssistantStreaming: true } }); const now = new Date().toISOString(); await Effect.runPromise( @@ -1371,7 +1386,6 @@ describe("ProviderRuntimeIngestion", () => { text: "stream please", attachments: [], }, - assistantDeliveryMode: "streaming", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0b7af8bd4b..234ad4e581 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -13,7 +13,7 @@ import { type OrchestrationThreadActivity, type ProviderRuntimeEvent, } from "@t3tools/contracts"; -import { Cache, Cause, Duration, Effect, Layer, Option, Ref, Stream } from "effect"; +import { Cache, Cause, Duration, Effect, Layer, Option, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; @@ -26,12 +26,12 @@ import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); -const DEFAULT_ASSISTANT_DELIVERY_MODE: AssistantDeliveryMode = "buffered"; const TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY = 10_000; const TURN_MESSAGE_IDS_BY_TURN_TTL = Duration.minutes(120); const BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY = 20_000; @@ -537,10 +537,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; - - const assistantDeliveryModeRef = yield* Ref.make( - DEFAULT_ASSISTANT_DELIVERY_MODE, - ); + const serverSettingsService = yield* ServerSettingsService; const turnMessageIdsByTurnKey = yield* Cache.make>({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1048,7 +1045,10 @@ const make = Effect.gen(function* () { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - const assistantDeliveryMode = yield* Ref.get(assistantDeliveryModeRef); + const serverSettings = yield* serverSettingsService.getSettings; + const assistantDeliveryMode: AssistantDeliveryMode = serverSettings.enableAssistantStreaming + ? "streaming" + : "buffered"; if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); if (spillChunk.length > 0) { @@ -1256,11 +1256,7 @@ const make = Effect.gen(function* () { ).pipe(Effect.asVoid); }); - const processDomainEvent = (event: TurnStartRequestedDomainEvent) => - Ref.set( - assistantDeliveryModeRef, - event.payload.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, - ); + const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void; const processInput = (input: RuntimeIngestionInput) => input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 761ab56a7d..c4cfd2314b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -14,7 +14,6 @@ import { } from "./commandInvariants.ts"; const nowIso = () => new Date().toISOString(); -const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], @@ -330,10 +329,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.providerOptions !== undefined - ? { providerOptions: command.providerOptions } - : {}), - assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 68fa9e8708..c102c6744e 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -27,6 +27,7 @@ import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; +import { ServerSettingsLive } from "./serverSettings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; @@ -108,11 +109,13 @@ export function makeServerRuntimeServicesLayer() { ); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(ServerSettingsLive), ); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(GitCoreLive), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(ServerSettingsLive), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), @@ -137,5 +140,6 @@ export function makeServerRuntimeServicesLayer() { gitManagerLayer, terminalLayer, KeybindingsLive, + ServerSettingsLive, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts new file mode 100644 index 0000000000..2cd9d4bf6c --- /dev/null +++ b/apps/server/src/serverSettings.ts @@ -0,0 +1,274 @@ +/** + * ServerSettings - Server-authoritative settings service. + * + * Owns persistence, validation, and change notification of settings that affect + * server-side behavior (binary paths, streaming mode, env mode, custom models, + * text generation model selection). + * + * Follows the same pattern as `keybindings.ts`: JSON file + Cache + PubSub + + * Semaphore + FileSystem.watch for concurrency and external edit detection. + * + * @module ServerSettings + */ +import { + DEFAULT_SERVER_SETTINGS, + type ProviderStartOptions, + ServerSettings, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { + Cache, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + Path, + PubSub, + Ref, + Schema, + SchemaGetter, + Scope, + ServiceMap, + Stream, +} from "effect"; +import * as Semaphore from "effect/Semaphore"; +import { ServerConfig } from "./config"; + +export class ServerSettingsError extends Schema.TaggedErrorClass()( + "ServerSettingsError", + { + settingsPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Server settings error at ${this.settingsPath}: ${this.detail}`; + } +} + +export interface ServerSettingsShape { + /** Start the settings runtime and attach file watching. */ + readonly start: Effect.Effect; + + /** Await settings runtime readiness. */ + readonly ready: Effect.Effect; + + /** Read the current settings. */ + readonly getSettings: Effect.Effect; + + /** Patch settings and persist. Returns the new full settings object. */ + readonly updateSettings: ( + patch: ServerSettingsPatch, + ) => Effect.Effect; + + /** Stream of settings change events. */ + readonly streamChanges: Stream.Stream; +} + +export class ServerSettingsService extends ServiceMap.Service< + ServerSettingsService, + ServerSettingsShape +>()("t3/serverSettings/ServerSettingsService") {} + +/** + * Derive `ProviderStartOptions` from server settings. + * Replaces the client-side `getProviderStartOptions()` function. + */ +export function deriveProviderStartOptions( + settings: ServerSettings, +): ProviderStartOptions | undefined { + const providerOptions: ProviderStartOptions = { + ...(settings.codexBinaryPath || settings.codexHomePath + ? { + codex: { + ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), + ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), + }, + } + : {}), + ...(settings.claudeBinaryPath + ? { + claudeAgent: { + binaryPath: settings.claudeBinaryPath, + }, + } + : {}), + }; + + return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; +} + +const ServerSettingsJson = Schema.fromJsonString(ServerSettings); +const PrettyJsonString = SchemaGetter.parseJson().compose( + SchemaGetter.stringifyJson({ space: 2 }), +); +const ServerSettingsPrettyJson = ServerSettingsJson.pipe( + Schema.encode({ + decode: PrettyJsonString, + encode: PrettyJsonString, + }), +); + +const makeServerSettings = Effect.gen(function* () { + const { settingsPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const writeSemaphore = yield* Semaphore.make(1); + const cacheKey = "settings" as const; + const changesPubSub = yield* PubSub.unbounded(); + const startedRef = yield* Ref.make(false); + const startedDeferred = yield* Deferred.make(); + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + + const emitChange = (settings: ServerSettings) => + PubSub.publish(changesPubSub, settings).pipe(Effect.asVoid); + + const readConfigExists = fs.exists(settingsPath).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to check settings file existence", + cause, + }), + ), + ); + + const readRawConfig = fs.readFileString(settingsPath).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to read settings file", + cause, + }), + ), + ); + + const loadSettingsFromDisk = Effect.gen(function* () { + if (!(yield* readConfigExists)) { + return DEFAULT_SERVER_SETTINGS; + } + + const raw = yield* readRawConfig; + const decoded = Schema.decodeUnknownExit(ServerSettingsJson)(raw); + if (decoded._tag === "Failure") { + yield* Effect.logWarning("failed to parse settings.json, using defaults", { + path: settingsPath, + }); + return DEFAULT_SERVER_SETTINGS; + } + return decoded.value; + }); + + const settingsCache = yield* Cache.make({ + capacity: 1, + lookup: () => loadSettingsFromDisk, + }); + + const getSettingsFromCache = Cache.get(settingsCache, cacheKey); + + const writeSettingsAtomically = (settings: ServerSettings) => { + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; + + return Schema.encodeEffect(ServerSettingsPrettyJson)(settings).pipe( + Effect.map((encoded) => `${encoded}\n`), + Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), + Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), + Effect.flatMap(() => fs.rename(tempPath, settingsPath)), + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to write settings file", + cause, + }), + ), + ); + }; + + const revalidateAndEmit = writeSemaphore.withPermits(1)( + Effect.gen(function* () { + yield* Cache.invalidate(settingsCache, cacheKey); + const settings = yield* getSettingsFromCache; + yield* emitChange(settings); + }), + ); + + const startWatcher = Effect.gen(function* () { + const settingsDir = pathService.dirname(settingsPath); + const settingsFile = pathService.basename(settingsPath); + const settingsPathResolved = pathService.resolve(settingsPath); + + yield* fs.makeDirectory(settingsDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + detail: "failed to prepare settings directory", + cause, + }), + ), + ); + + const revalidateAndEmitSafely = revalidateAndEmit.pipe(Effect.ignoreCause({ log: true })); + + yield* Stream.runForEach(fs.watch(settingsDir), (event) => { + const isTargetEvent = + event.path === settingsFile || + event.path === settingsPath || + pathService.resolve(settingsDir, event.path) === settingsPathResolved; + if (!isTargetEvent) { + return Effect.void; + } + return revalidateAndEmitSafely; + }).pipe(Effect.ignoreCause({ log: true }), Effect.forkIn(watcherScope), Effect.asVoid); + }); + + const start = Effect.gen(function* () { + const alreadyStarted = yield* Ref.get(startedRef); + if (alreadyStarted) { + return yield* Deferred.await(startedDeferred); + } + + yield* Ref.set(startedRef, true); + const startup = Effect.gen(function* () { + yield* startWatcher; + yield* Cache.invalidate(settingsCache, cacheKey); + yield* getSettingsFromCache; + }); + + const startupExit = yield* Effect.exit(startup); + if (startupExit._tag === "Failure") { + yield* Deferred.failCause(startedDeferred, startupExit.cause).pipe(Effect.orDie); + return yield* Effect.failCause(startupExit.cause); + } + + yield* Deferred.succeed(startedDeferred, undefined).pipe(Effect.orDie); + }); + + return { + start, + ready: Deferred.await(startedDeferred), + getSettings: getSettingsFromCache, + updateSettings: (patch) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* getSettingsFromCache; + const next: ServerSettings = { ...current, ...patch }; + yield* writeSettingsAtomically(next); + yield* Cache.set(settingsCache, cacheKey, next); + yield* emitChange(next); + return next; + }), + ), + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + } satisfies ServerSettingsShape; +}); + +export const ServerSettingsLive = Layer.effect(ServerSettingsService, makeServerSettings); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bcb3850e7a..9696aafe72 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -49,6 +49,7 @@ import { createLogger } from "./logger"; import { GitManager } from "./git/Services/GitManager.ts"; import { TerminalManager } from "./terminal/Services/Manager.ts"; import { Keybindings } from "./keybindings"; +import { ServerSettingsService } from "./serverSettings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; @@ -216,6 +217,7 @@ export type ServerRuntimeServices = | GitCore | TerminalManager | Keybindings + | ServerSettingsService | Open | AnalyticsService; @@ -253,6 +255,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; + const serverSettingsManager = yield* ServerSettingsService; const providerHealth = yield* ProviderHealth; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; @@ -295,6 +298,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); yield* readiness.markKeybindingsReady; + yield* serverSettingsManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), + ), + ); const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { readonly command: ClientOrchestrationCommand; @@ -618,6 +626,14 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }), ).pipe(Effect.forkIn(subscriptionsScope)); + yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: [], + providers: providerStatuses, + settings, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); yield* readiness.markOrchestrationSubscriptionsReady; @@ -878,8 +894,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* terminalManager.close(body); } - case WS_METHODS.serverGetConfig: + case WS_METHODS.serverGetConfig: { const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const settings = yield* serverSettingsManager.getSettings; return { cwd, keybindingsConfigPath, @@ -887,7 +904,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< issues: keybindingsConfig.issues, providers: providerStatuses, availableEditors, + settings, }; + } case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); @@ -895,6 +914,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.serverGetSettings: { + return yield* serverSettingsManager.getSettings; + } + + case WS_METHODS.serverUpdateSettings: { + const body = stripRequestTag(request.body); + return yield* serverSettingsManager.updateSettings(body.patch); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index fea74edd72..d0d5ae53eb 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,12 +1,9 @@ -import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { - AppSettingsSchema, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, DEFAULT_TIMESTAMP_FORMAT, - getProviderStartOptions, } from "./appSettings"; import { getAppModelOptions, @@ -134,35 +131,6 @@ describe("provider-specific custom models", () => { }); }); -describe("getProviderStartOptions", () => { - it("returns only populated provider overrides", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "/usr/local/bin/claude", - codexBinaryPath: "", - codexHomePath: "/Users/you/.codex", - }), - ).toEqual({ - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - }, - codex: { - homePath: "/Users/you/.codex", - }, - }); - }); - - it("returns undefined when no provider overrides are configured", () => { - expect( - getProviderStartOptions({ - claudeBinaryPath: "", - codexBinaryPath: "", - codexHomePath: "", - }), - ).toBeUndefined(); - }); -}); - describe("provider-indexed custom model settings", () => { const settings = { customCodexModels: ["custom/codex-model"], @@ -242,33 +210,6 @@ describe("provider-indexed custom model settings", () => { }); }); -describe("AppSettingsSchema", () => { - it("fills decoding defaults for persisted settings that predate newer keys", () => { - const decode = Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema)); - - expect( - decode( - JSON.stringify({ - codexBinaryPath: "/usr/local/bin/codex", - confirmThreadDelete: false, - }), - ), - ).toMatchObject({ - claudeBinaryPath: "", - codexBinaryPath: "/usr/local/bin/codex", - codexHomePath: "", - defaultThreadEnvMode: "local", - confirmThreadDelete: false, - enableAssistantStreaming: false, - sidebarProjectSortOrder: DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - sidebarThreadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - timestampFormat: DEFAULT_TIMESTAMP_FORMAT, - customCodexModels: [], - customClaudeModels: [], - }); - }); -}); - describe("resolveAppModelSelectionState", () => { it("falls back to the default git-writing codex selection", () => { expect( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e2aac52a84..de348c8bd3 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,121 +1,50 @@ -import { useCallback } from "react"; -import { Option, Schema } from "effect"; +/** + * App Settings - Backward-compatible shim. + * + * Re-exports the unified settings hook (`useSettings` / `useUpdateSettings`) + * as `useAppSettings()` so existing consumers continue to work without + * modification. New code should import from `~/hooks/useSettings` directly. + * + * Also re-exports type aliases and schema constants consumed by components + * that only need the type (e.g. `TimestampFormat`). + */ +import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts"; +import { useSettings, useUpdateSettings, type UnifiedSettings } from "./hooks/useSettings"; import { - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, - ModelSelection, - type ProviderStartOptions, -} from "@t3tools/contracts"; -import { useLocalStorage } from "./hooks/useLocalStorage"; -import { EnvMode } from "./components/BranchToolbar.logic"; -import { normalizeCustomModelSlugs } from "./modelSelection"; + DEFAULT_CLIENT_SETTINGS, + type ClientSettings, + type TimestampFormat, + type SidebarProjectSortOrder, + type SidebarThreadSortOrder, + DEFAULT_TIMESTAMP_FORMAT, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +} from "./clientSettings"; -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; +// ── Re-exports for downstream type consumers ───────────────────────── -export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); -export type TimestampFormat = typeof TimestampFormat.Type; -export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); -export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; -export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; -export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); -export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; -export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export type { TimestampFormat, SidebarProjectSortOrder, SidebarThreadSortOrder }; +export { + DEFAULT_TIMESTAMP_FORMAT, + DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, + DEFAULT_SIDEBAR_THREAD_SORT_ORDER, +}; +export type { UnifiedSettings }; -const withDefaults = - < - S extends Schema.Top & Schema.WithoutConstructorDefault, - D extends S["~type.make.in"] & S["Encoded"], - >( - fallback: () => D, - ) => - (schema: S) => - schema.pipe( - Schema.withConstructorDefault(() => Option.some(fallback())), - Schema.withDecodingDefault(() => fallback()), - ); +// ── Backward-compat type alias ─────────────────────────────────────── -export const AppSettingsSchema = Schema.Struct({ - claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), - defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), - confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), - diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), - enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), - sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), - ), - sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( - withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), - ), - timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), - customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), - textGenerationModelSelection: ModelSelection.pipe( - withDefaults(() => ({ - provider: "codex" as const, - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - })), - ), -}); -export type AppSettings = typeof AppSettingsSchema.Type; +export type AppSettings = UnifiedSettings; -const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); - -function normalizeAppSettings(settings: AppSettings): AppSettings { - return { - ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), - customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), - }; -} - -export function getProviderStartOptions( - settings: Pick, -): ProviderStartOptions | undefined { - const providerOptions: ProviderStartOptions = { - ...(settings.codexBinaryPath || settings.codexHomePath - ? { - codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), - }, - } - : {}), - ...(settings.claudeBinaryPath - ? { - claudeAgent: { - binaryPath: settings.claudeBinaryPath, - }, - } - : {}), - }; - - return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; -} +// ── Backward-compat hook ───────────────────────────────────────────── export function useAppSettings() { - const [settings, setSettings] = useLocalStorage( - APP_SETTINGS_STORAGE_KEY, - DEFAULT_APP_SETTINGS, - AppSettingsSchema, - ); - - const updateSettings = useCallback( - (patch: Partial) => { - setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); - }, - [setSettings], - ); - - const resetSettings = useCallback(() => { - setSettings(DEFAULT_APP_SETTINGS); - }, [setSettings]); + const settings = useSettings(); + const { updateSettings, resetSettings, defaults } = useUpdateSettings(); return { settings, - updateSettings, + updateSettings: (patch: Partial) => updateSettings(patch), resetSettings, - defaults: DEFAULT_APP_SETTINGS, + defaults, } as const; } diff --git a/apps/web/src/clientSettings.ts b/apps/web/src/clientSettings.ts new file mode 100644 index 0000000000..b44da242ea --- /dev/null +++ b/apps/web/src/clientSettings.ts @@ -0,0 +1,50 @@ +/** + * Client-only settings - persisted in localStorage. + * + * These settings affect only the frontend UX and never need to be read by + * the server. Server-authoritative settings live in `ServerSettings` + * (defined in `@t3tools/contracts/server`). + */ +import { Option, Schema } from "effect"; + +export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; + +export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); +export type TimestampFormat = typeof TimestampFormat.Type; +export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; + +export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); +export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; +export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; + +export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); +export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; +export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; + +const withDefaults = + < + S extends Schema.Top & Schema.WithoutConstructorDefault, + D extends S["~type.make.in"] & S["Encoded"], + >( + fallback: () => D, + ) => + (schema: S) => + schema.pipe( + Schema.withConstructorDefault(() => Option.some(fallback())), + Schema.withDecodingDefault(() => fallback()), + ); + +export const ClientSettingsSchema = Schema.Struct({ + confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), + diffWordWrap: Schema.Boolean.pipe(withDefaults(() => false)), + sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( + withDefaults(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), + ), + sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( + withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), + ), + timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), +}); +export type ClientSettings = typeof ClientSettingsSchema.Type; + +export const DEFAULT_CLIENT_SETTINGS: ClientSettings = ClientSettingsSchema.makeUnsafe({}); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4e18092463..0f85642ff2 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -117,6 +117,16 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + settings: { + claudeBinaryPath: "", + codexBinaryPath: "", + codexHomePath: "", + enableAssistantStreaming: false, + defaultThreadEnvMode: "local" as const, + customCodexModels: [], + customClaudeModels: [], + textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + }, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ace74a5cc8..6fb6cea214 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -120,7 +120,7 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getProviderStartOptions, useAppSettings } from "../appSettings"; +import { useAppSettings } from "../appSettings"; import { getCustomModelOptionsByProvider, getCustomModelsByProvider, @@ -638,7 +638,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); - const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings, selectedProvider, selectedModel), @@ -2645,8 +2644,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2927,8 +2924,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -2974,7 +2969,6 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, setThreadError, @@ -3044,8 +3038,6 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3096,9 +3088,7 @@ export default function ChatView({ threadId }: ChatViewProps) { runtimeMode, selectedPromptEffort, selectedModelSelection, - providerOptionsForDispatch, selectedProvider, - settings.enableAssistantStreaming, syncServerReadModel, selectedModel, ]); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7cb55e795c..8b81ce0816 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -53,6 +53,16 @@ function createBaseServerConfig(): ServerConfig { }, ], availableEditors: [], + settings: { + claudeBinaryPath: "", + codexBinaryPath: "", + codexHomePath: "", + enableAssistantStreaming: false, + defaultThreadEnvMode: "local" as const, + customCodexModels: [], + customClaudeModels: [], + textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + }, }; } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts new file mode 100644 index 0000000000..d226eeb4d6 --- /dev/null +++ b/apps/web/src/hooks/useSettings.ts @@ -0,0 +1,175 @@ +/** + * Unified settings hook. + * + * Abstracts the split between server-authoritative settings (persisted in + * `settings.json` on the server, fetched via `server.getConfig`) and + * client-only settings (persisted in localStorage). + * + * Consumers use `useSettings(selector)` to read, and `useUpdateSettings()` to + * write. The hook transparently routes reads/writes to the correct backing + * store. + */ +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ServerSettings, ServerSettingsPatch, ServerConfig } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; +import { ensureNativeApi } from "~/nativeApi"; +import { useLocalStorage } from "./useLocalStorage"; +import { + ClientSettingsSchema, + type ClientSettings, + DEFAULT_CLIENT_SETTINGS, + CLIENT_SETTINGS_STORAGE_KEY, +} from "~/clientSettings"; +import { Struct } from "effect"; + +// ── Unified type ───────────────────────────────────────────────────── + +export type UnifiedSettings = ServerSettings & ClientSettings; + +// ── Key sets for routing patches ───────────────────────────────────── + +const SERVER_SETTINGS_KEYS = new Set(Struct.keys(ServerSettings.fields)); + +function splitPatch(patch: Partial): { + serverPatch: ServerSettingsPatch; + clientPatch: Partial; +} { + const serverPatch: Record = {}; + const clientPatch: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (SERVER_SETTINGS_KEYS.has(key)) { + serverPatch[key] = value; + } else { + clientPatch[key] = value; + } + } + return { + serverPatch: serverPatch as ServerSettingsPatch, + clientPatch: clientPatch as Partial, + }; +} + +// ── Hooks ──────────────────────────────────────────────────────────── + +/** + * Read merged settings. Selector narrows the subscription so components + * only re-render when the slice they care about changes. + */ + +export function useSettings( + selector?: (s: UnifiedSettings) => T, +): T { + const { data: serverConfig } = useQuery(serverConfigQueryOptions()); + const [clientSettings] = useLocalStorage( + CLIENT_SETTINGS_STORAGE_KEY, + DEFAULT_CLIENT_SETTINGS, + ClientSettingsSchema, + ); + + const merged = useMemo( + () => ({ + ...(serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS), + ...clientSettings, + }), + [serverConfig?.settings, clientSettings], + ); + + return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]); +} + +/** + * Returns an updater that routes each key to the correct backing store. + * + * Server keys are optimistically patched in the React Query cache, then + * persisted via RPC. Client keys go straight to localStorage. + */ +export function useUpdateSettings() { + const queryClient = useQueryClient(); + const [, setClientSettings] = useLocalStorage( + CLIENT_SETTINGS_STORAGE_KEY, + DEFAULT_CLIENT_SETTINGS, + ClientSettingsSchema, + ); + + const updateSettings = useCallback( + (patch: Partial) => { + const { serverPatch, clientPatch } = splitPatch(patch); + + if (Object.keys(serverPatch).length > 0) { + // Optimistic update of the React Query cache + queryClient.setQueryData(serverQueryKeys.config(), (old) => { + if (!old) return old; + return { + ...old, + settings: { ...old.settings, ...serverPatch }, + }; + }); + // Fire-and-forget RPC — push will reconcile on success + void ensureNativeApi().server.updateSettings(serverPatch); + } + + if (Object.keys(clientPatch).length > 0) { + setClientSettings((prev) => ({ ...prev, ...clientPatch })); + } + }, + [queryClient, setClientSettings], + ); + + const resetSettings = useCallback(() => { + updateSettings({ + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }); + }, [updateSettings]); + + return { + updateSettings, + resetSettings, + defaults: { ...DEFAULT_SERVER_SETTINGS, ...DEFAULT_CLIENT_SETTINGS } as UnifiedSettings, + }; +} + +// ── One-time migration from localStorage ───────────────────────────── + +const MIGRATION_FLAG_KEY = "t3code:settings-migrated:v1"; +const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; + +/** + * Call once on app startup. Migrates server-relevant settings from the + * old localStorage key to the server. Idempotent. + */ +export function migrateLocalSettingsToServer(): void { + if (typeof window === "undefined") return; + if (localStorage.getItem(MIGRATION_FLAG_KEY)) return; + + const raw = localStorage.getItem(OLD_SETTINGS_KEY); + if (!raw) { + localStorage.setItem(MIGRATION_FLAG_KEY, "true"); + return; + } + + try { + const old = JSON.parse(raw) as Record; + const serverPatch: Record = {}; + for (const key of SERVER_SETTINGS_KEYS) { + if (key in old && old[key] !== undefined) { + serverPatch[key] = old[key]; + } + } + + if (Object.keys(serverPatch).length > 0) { + void ensureNativeApi() + .server.updateSettings(serverPatch as ServerSettingsPatch) + .then(() => { + localStorage.setItem(MIGRATION_FLAG_KEY, "true"); + }); + } else { + localStorage.setItem(MIGRATION_FLAG_KEY, "true"); + } + } catch { + // If parsing fails, mark as migrated to avoid retrying + localStorage.setItem(MIGRATION_FLAG_KEY, "true"); + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..496f27e15e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -21,6 +21,7 @@ import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; +import { migrateLocalSettingsToServer } from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; @@ -230,6 +231,8 @@ function EventRouter() { ); }); const unsubWelcome = onServerWelcome((payload) => { + // Migrate old localStorage settings to server on first connect + migrateLocalSettingsToServer(); void (async () => { await syncSnapshot(); if (disposed) { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 042875f6f7..d258d27dd9 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -179,6 +179,8 @@ export function createWsNativeApi(): NativeApi { server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + getSettings: () => transport.request(WS_METHODS.serverGetSettings), + updateSettings: (patch) => transport.request(WS_METHODS.serverUpdateSettings, { patch }), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea73024de3..059b88e9fb 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -25,7 +25,7 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import type { ServerConfig } from "./server"; +import type { ServerConfig, ServerSettings, ServerSettingsPatch } from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -161,6 +161,8 @@ export interface NativeApi { server: { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + getSettings: () => Promise; + updateSettings: (patch: ServerSettingsPatch) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 333d5ca1eb..9ce78d7457 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -402,8 +402,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -423,8 +421,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), - providerOptions: Schema.optional(ProviderStartOptions), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, sourceProposedPlan: Schema.optional(SourceProposedPlanReference), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..b8355be3bb 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,8 +1,38 @@ -import { Schema } from "effect"; +import { Schema, Struct } from "effect"; import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; -import { ProviderKind } from "./orchestration"; +import { DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER } from "./model"; +import { ModelSelection, ProviderKind } from "./orchestration"; + +// ── Server Settings (server-authoritative) ─────────────────────────── + +export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); +export type ThreadEnvMode = typeof ThreadEnvMode.Type; + +export const ServerSettings = Schema.Struct({ + claudeBinaryPath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), + codexBinaryPath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), + codexHomePath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), + enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + defaultThreadEnvMode: ThreadEnvMode.pipe( + Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), + ), + customCodexModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), + customClaudeModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), + textGenerationModelSelection: ModelSelection.pipe( + Schema.withDecodingDefault(() => ({ + provider: "codex" as const, + model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + })), + ), +}); +export type ServerSettings = typeof ServerSettings.Type; + +export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeUnknownSync(ServerSettings)({}); + +export const ServerSettingsPatch = ServerSettings.mapFields(Struct.map(Schema.optionalKey)); +export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -52,6 +82,7 @@ export const ServerConfig = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviderStatuses, availableEditors: Schema.Array(EditorId), + settings: ServerSettings, }); export type ServerConfig = typeof ServerConfig.Type; @@ -67,5 +98,6 @@ export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.T export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviderStatuses, + settings: Schema.optional(ServerSettings), }); export type ServerConfigUpdatedPayload = typeof ServerConfigUpdatedPayload.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 45ef0512da..e082aad483 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -37,7 +37,7 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; -import { ServerConfigUpdatedPayload } from "./server"; +import { ServerConfigUpdatedPayload, ServerSettingsPatch } from "./server"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -76,6 +76,8 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", + serverGetSettings: "server.getSettings", + serverUpdateSettings: "server.updateSettings", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -141,6 +143,8 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + tagRequestBody(WS_METHODS.serverGetSettings, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverUpdateSettings, Schema.Struct({ patch: ServerSettingsPatch })), ]); export const WebSocketRequest = Schema.Struct({ From 3f089a454b2961e2987299da4bb1b87a803c5f95 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Mar 2026 13:57:01 -0700 Subject: [PATCH 02/30] layers --- .../OrchestrationEngineHarness.integration.ts | 7 +-- .../git/Layers/ClaudeTextGeneration.test.ts | 2 + .../src/git/Layers/ClaudeTextGeneration.ts | 9 +++- .../git/Layers/CodexTextGeneration.test.ts | 2 + .../src/git/Layers/CodexTextGeneration.ts | 10 +++- apps/server/src/main.ts | 2 + .../Layers/ProviderCommandReactor.test.ts | 4 +- .../Layers/ProviderCommandReactor.ts | 25 +++++---- .../Layers/ProviderRuntimeIngestion.ts | 8 +-- apps/server/src/serverLayers.ts | 4 -- apps/server/src/serverSettings.ts | 21 +++++--- apps/server/src/wsServer.test.ts | 2 + apps/web/src/components/ChatView.browser.tsx | 5 +- .../components/KeybindingsToast.browser.tsx | 5 +- apps/web/src/routes/_chat.settings.tsx | 53 ++++++++++++------- packages/contracts/src/server.ts | 17 ++++-- 16 files changed, 114 insertions(+), 62 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 04230ef3f9..f2ad289589 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -67,7 +67,7 @@ import { type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; import { deriveServerPaths, ServerConfig } from "../src/config.ts"; -import { ServerSettingsLive } from "../src/serverSettings.ts"; +import { ServerSettingsService } from "../src/serverSettings.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -296,10 +296,7 @@ export const makeOrchestrationIntegrationHarness = ( providerLayer, RuntimeReceiptBusLive, ); - const serverSettingsLayer = ServerSettingsLive.pipe( - Layer.provide(ServerConfig.layerTest(workspaceDir, rootDir)), - Layer.provide(NodeServices.layer), - ); + const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 0a3829798e..29ae4796fe 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -6,8 +6,10 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const ClaudeTextGenerationTestLayer = ClaudeTextGenerationLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-claude-text-generation-test-", diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 9f48a07c51..66fb3cbed7 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -27,6 +27,7 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -40,6 +41,7 @@ const ClaudeOutputEnvelope = Schema.Struct({ const makeClaudeTextGeneration = Effect.gen(function* () { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettingsService = yield* Effect.service(ServerSettingsService); const readStreamAsString = ( operation: string, @@ -86,9 +88,14 @@ const makeClaudeTextGeneration = Effect.gen(function* () { ...(normalizedOptions?.fastMode ? { fastMode: true } : {}), }; + const claudeSettings = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.claude, + ).pipe(Effect.catch(() => Effect.undefined)); + const runClaudeCommand = Effect.gen(function* () { const command = ChildProcess.make( - "claude", + claudeSettings?.binaryPath ?? "claude", [ "-p", "--output-format", diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index b53d7f15bd..20c0287825 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -7,6 +7,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const DEFAULT_TEST_MODEL_SELECTION = { provider: "codex" as const, @@ -14,6 +15,7 @@ const DEFAULT_TEST_MODEL_SELECTION = { }; const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { prefix: "t3code-codex-text-generation-test-", diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index afe972ab4a..194cb990a9 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -26,6 +26,7 @@ import { sanitizePrTitle, toJsonSchemaObject, } from "../Utils.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -35,6 +36,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); + const serverSettingsService = yield* Effect.service(ServerSettingsService); type MaterializedImageAttachments = { readonly imagePaths: ReadonlyArray; @@ -138,6 +140,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); + const codexSettings = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.codex, + ).pipe(Effect.catch(() => Effect.undefined)); + const runCodexCommand = Effect.gen(function* () { const normalizedOptions = normalizeCodexModelOptions( modelSelection.model, @@ -146,7 +153,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const reasoningEffort = modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; const command = ChildProcess.make( - "codex", + codexSettings?.binaryPath ?? "codex", [ "exec", "--ephemeral", @@ -165,6 +172,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { "-", ], { + env: codexSettings?.homePath ? { CODEX_HOME: codexSettings.homePath } : {}, cwd, shell: process.platform === "win32", stdin: { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5b21252884..58412ee53f 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -28,6 +28,7 @@ import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { readBootstrapEnvelope } from "./bootstrap"; +import { ServerSettingsLive } from "./serverSettings"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -297,6 +298,7 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(ServerConfigLive(input)), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f19476abe8..834ab9be9e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -17,7 +17,6 @@ import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effe import { afterEach, describe, expect, it, vi } from "vitest"; import { deriveServerPaths, ServerConfig } from "../../config.ts"; -import { ServerSettingsLive } from "../../serverSettings.ts"; import { TextGenerationError } from "../../git/Errors.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -35,6 +34,7 @@ import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ServerSettingsService } from "../../serverSettings.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => @@ -215,7 +215,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), - Layer.provideMerge(ServerSettingsLive), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 082c4e54a0..23be0ab803 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,7 +1,6 @@ import { type ChatAttachment, CommandId, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, EventId, type ModelSelection, type OrchestrationEvent, @@ -362,9 +361,11 @@ const make = Effect.gen(function* () { if (!thread) { return; } - yield* ensureSessionForThread(input.threadId, input.createdAt, { - ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), - }); + yield* ensureSessionForThread( + input.threadId, + input.createdAt, + input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}, + ); if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } @@ -433,10 +434,10 @@ const make = Effect.gen(function* () { cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection: { - provider: "codex", - model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - }, + modelSelection: yield* Effect.map( + serverSettingsService.getSettings, + (settings) => settings.textGenerationModelSelection, + ), }) .pipe( Effect.catch((error) => @@ -680,9 +681,11 @@ const make = Effect.gen(function* () { return; } const cachedModelSelection = threadModelSelections.get(event.payload.threadId); - yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { - ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), - }); + yield* ensureSessionForThread( + event.payload.threadId, + event.occurredAt, + cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}, + ); return; } case "thread.turn-start-requested": diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 234ad4e581..f9a662b84f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1045,10 +1045,10 @@ const make = Effect.gen(function* () { yield* rememberAssistantMessageId(thread.id, turnId, assistantMessageId); } - const serverSettings = yield* serverSettingsService.getSettings; - const assistantDeliveryMode: AssistantDeliveryMode = serverSettings.enableAssistantStreaming - ? "streaming" - : "buffered"; + const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( + serverSettingsService.getSettings, + (settings) => (settings.enableAssistantStreaming ? "streaming" : "buffered"), + ); if (assistantDeliveryMode === "buffered") { const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); if (spillChunk.length > 0) { diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index c102c6744e..68fa9e8708 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -27,7 +27,6 @@ import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; -import { ServerSettingsLive } from "./serverSettings"; import { GitManagerLive } from "./git/Layers/GitManager"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; @@ -109,13 +108,11 @@ export function makeServerRuntimeServicesLayer() { ); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(ServerSettingsLive), ); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(GitCoreLive), Layer.provideMerge(textGenerationLayer), - Layer.provideMerge(ServerSettingsLive), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), @@ -140,6 +137,5 @@ export function makeServerRuntimeServicesLayer() { gitManagerLayer, terminalLayer, KeybindingsLive, - ServerSettingsLive, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 2cd9d4bf6c..bb28ad7132 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -70,7 +70,16 @@ export interface ServerSettingsShape { export class ServerSettingsService extends ServiceMap.Service< ServerSettingsService, ServerSettingsShape ->()("t3/serverSettings/ServerSettingsService") {} +>()("t3/serverSettings/ServerSettingsService") { + static readonly layerTest = (overrides: Partial = {}) => + Layer.succeed(ServerSettingsService, { + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed({ ...DEFAULT_SERVER_SETTINGS, ...overrides }), + updateSettings: () => Effect.succeed({ ...DEFAULT_SERVER_SETTINGS, ...overrides }), + streamChanges: Stream.empty, + } satisfies ServerSettingsShape); +} /** * Derive `ProviderStartOptions` from server settings. @@ -80,18 +89,18 @@ export function deriveProviderStartOptions( settings: ServerSettings, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { - ...(settings.codexBinaryPath || settings.codexHomePath + ...(settings.codex ? { codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), + ...(settings.codex.binaryPath ? { binaryPath: settings.codex.binaryPath } : {}), + ...(settings.codex.homePath ? { homePath: settings.codex.homePath } : {}), }, } : {}), - ...(settings.claudeBinaryPath + ...(settings.claude ? { claudeAgent: { - binaryPath: settings.claudeBinaryPath, + binaryPath: settings.claude.binaryPath, }, } : {}), diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 7dc4a59e7c..c0045167f6 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -53,6 +53,7 @@ import { GitCore } from "./git/Services/GitCore.ts"; import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -545,6 +546,7 @@ describe("WebSocket Server", () => { Layer.provideMerge(runtimeLayer), Layer.provideMerge(providerHealthLayer), Layer.provideMerge(openLayer), + Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(serverConfigLayer), Layer.provideMerge(AnalyticsService.layerTest), Layer.provideMerge(NodeServices.layer), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0f85642ff2..3594efbdc5 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -118,9 +118,8 @@ function createBaseServerConfig(): ServerConfig { ], availableEditors: [], settings: { - claudeBinaryPath: "", - codexBinaryPath: "", - codexHomePath: "", + claude: { binaryPath: "" }, + codex: { binaryPath: "", homePath: "" }, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, customCodexModels: [], diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 8b81ce0816..8d674a2a90 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -54,9 +54,8 @@ function createBaseServerConfig(): ServerConfig { ], availableEditors: [], settings: { - claudeBinaryPath: "", - codexBinaryPath: "", - codexHomePath: "", + claude: { binaryPath: "" }, + codex: { binaryPath: "", homePath: "" }, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, customCodexModels: [], diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 62a27edba9..38e324dac1 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -195,8 +195,8 @@ function SettingsRouteView() { const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), - claudeAgent: Boolean(settings.claudeBinaryPath), + codex: Boolean(settings.codex?.binaryPath || settings.codex?.homePath), + claudeAgent: Boolean(settings.claude?.binaryPath), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); @@ -211,9 +211,9 @@ function SettingsRouteView() { >({}); const [showAllCustomModels, setShowAllCustomModels] = useState(false); - const codexBinaryPath = settings.codexBinaryPath; - const codexHomePath = settings.codexHomePath; - const claudeBinaryPath = settings.claudeBinaryPath; + const codexBinaryPath = settings.codex?.binaryPath ?? ""; + const codexHomePath = settings.codex?.homePath ?? ""; + const claudeBinaryPath = settings.claude?.binaryPath ?? ""; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -244,9 +244,9 @@ function SettingsRouteView() { ? savedCustomModelRows : savedCustomModelRows.slice(0, 5); const isInstallSettingsDirty = - settings.claudeBinaryPath !== defaults.claudeBinaryPath || - settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath; + settings.claude?.binaryPath !== defaults.claude?.binaryPath || + settings.codex?.binaryPath !== defaults.codex?.binaryPath || + settings.codex?.homePath !== defaults.codex?.homePath; const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), @@ -831,9 +831,11 @@ function SettingsRouteView() { label="provider installs" onClick={() => { updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, + claude: { binaryPath: defaults.claude?.binaryPath ?? "" }, + codex: { + binaryPath: defaults.codex?.binaryPath ?? "", + homePath: defaults.codex?.homePath ?? "", + }, }); setOpenInstallProviders({ codex: false, @@ -850,12 +852,12 @@ function SettingsRouteView() { const isOpen = openInstallProviders[providerSettings.provider]; const isDirty = providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; + ? settings.codex?.binaryPath !== defaults.codex?.binaryPath || + settings.codex?.homePath !== defaults.codex?.homePath + : settings.claude?.binaryPath !== defaults.claude?.binaryPath; const binaryPathValue = providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath + ? (settings.claude?.binaryPath ?? "") : codexBinaryPath; return ( @@ -910,9 +912,19 @@ function SettingsRouteView() { value={binaryPathValue} onChange={(event) => updateSettings( - providerSettings.binaryPathKey === "claudeBinaryPath" - ? { claudeBinaryPath: event.target.value } - : { codexBinaryPath: event.target.value }, + providerSettings.provider === "claudeAgent" + ? { + claude: { + ...settings.claude, + binaryPath: event.target.value, + }, + } + : { + codex: { + ...settings.codex, + binaryPath: event.target.value, + }, + }, ) } placeholder={providerSettings.binaryPlaceholder} @@ -937,7 +949,10 @@ function SettingsRouteView() { value={codexHomePath} onChange={(event) => updateSettings({ - codexHomePath: event.target.value, + codex: { + ...settings.codex, + homePath: event.target.value, + }, }) } placeholder={providerSettings.homePlaceholder} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b8355be3bb..159a3f9ad4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -10,10 +10,21 @@ import { ModelSelection, ProviderKind } from "./orchestration"; export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; +export const CodexSettings = Schema.Struct({ + binaryPath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), + homePath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), +}); +export type CodexSettings = typeof CodexSettings.Type; + +export const ClaudeSettings = Schema.Struct({ + binaryPath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), +}); +export type ClaudeSettings = typeof ClaudeSettings.Type; + export const ServerSettings = Schema.Struct({ - claudeBinaryPath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), - codexBinaryPath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), - codexHomePath: Schema.String.pipe(Schema.withDecodingDefault(() => "")), + codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), + claude: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(() => "local" as const satisfies ThreadEnvMode), From a9aa5c4a057c1a5cb9fbdd5c4170809186cc88ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Mar 2026 14:26:17 -0700 Subject: [PATCH 03/30] Centralize provider settings under server authority - Move Codex and Claude config lookups to `settings.providers` - Update web composer, settings UI, and model selection to use unified settings - Refresh browser fixtures and contracts for the new settings shape --- .../src/git/Layers/ClaudeTextGeneration.ts | 2 +- .../src/git/Layers/CodexTextGeneration.ts | 2 +- apps/server/src/serverSettings.ts | 14 +- apps/web/src/appSettings.test.ts | 266 ------------------ apps/web/src/components/ChatView.browser.tsx | 11 +- apps/web/src/components/ChatView.tsx | 13 +- .../components/KeybindingsToast.browser.tsx | 8 +- .../components/chat/TraitsPicker.browser.tsx | 7 +- apps/web/src/composerDraftStore.ts | 15 +- apps/web/src/modelSelection.ts | 77 +---- apps/web/src/routes/_chat.settings.tsx | 137 +++++---- packages/contracts/src/server.ts | 13 +- 12 files changed, 137 insertions(+), 428 deletions(-) delete mode 100644 apps/web/src/appSettings.test.ts diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 66fb3cbed7..a85ed3f90c 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -90,7 +90,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () { const claudeSettings = yield* Effect.map( serverSettingsService.getSettings, - (settings) => settings.claude, + (settings) => settings.providers.claudeAgent, ).pipe(Effect.catch(() => Effect.undefined)); const runClaudeCommand = Effect.gen(function* () { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 194cb990a9..2b16f675e5 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -142,7 +142,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { const codexSettings = yield* Effect.map( serverSettingsService.getSettings, - (settings) => settings.codex, + (settings) => settings.providers.codex, ).pipe(Effect.catch(() => Effect.undefined)); const runCodexCommand = Effect.gen(function* () { diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index bb28ad7132..c156c31bed 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -89,18 +89,22 @@ export function deriveProviderStartOptions( settings: ServerSettings, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { - ...(settings.codex + ...(settings.providers.codex ? { codex: { - ...(settings.codex.binaryPath ? { binaryPath: settings.codex.binaryPath } : {}), - ...(settings.codex.homePath ? { homePath: settings.codex.homePath } : {}), + ...(settings.providers.codex.binaryPath + ? { binaryPath: settings.providers.codex.binaryPath } + : {}), + ...(settings.providers.codex.homePath + ? { homePath: settings.providers.codex.homePath } + : {}), }, } : {}), - ...(settings.claude + ...(settings.providers.claudeAgent ? { claudeAgent: { - binaryPath: settings.claude.binaryPath, + binaryPath: settings.providers.claudeAgent.binaryPath, }, } : {}), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts deleted file mode 100644 index d0d5ae53eb..0000000000 --- a/apps/web/src/appSettings.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - DEFAULT_TIMESTAMP_FORMAT, -} from "./appSettings"; -import { - getAppModelOptions, - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - getCustomModelsForProvider, - getDefaultCustomModelsForProvider, - MODEL_PROVIDER_SETTINGS, - normalizeCustomModelSlugs, - patchCustomModels, - resolveAppModelSelectionState, - resolveAppModelSelection, -} from "./modelSelection"; - -describe("normalizeCustomModelSlugs", () => { - it("normalizes aliases, removes built-ins, and deduplicates values", () => { - expect( - normalizeCustomModelSlugs([ - " custom/internal-model ", - "gpt-5.3-codex", - "5.3", - "custom/internal-model", - "", - null, - ]), - ).toEqual(["custom/internal-model"]); - }); - - it("normalizes provider-specific aliases for claude", () => { - expect(normalizeCustomModelSlugs(["sonnet"], "claudeAgent")).toEqual([]); - expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeAgent")).toEqual([ - "claude/custom-sonnet", - ]); - }); -}); - -describe("getAppModelOptions", () => { - it("appends saved custom models after the built-in options", () => { - const options = getAppModelOptions("codex", ["custom/internal-model"]); - - expect(options.map((option) => option.slug)).toEqual([ - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2-codex", - "gpt-5.2", - "custom/internal-model", - ]); - }); - - it("keeps the currently selected custom model available even if it is no longer saved", () => { - const options = getAppModelOptions("codex", [], "custom/selected-model"); - - expect(options.at(-1)).toEqual({ - slug: "custom/selected-model", - name: "custom/selected-model", - isCustom: true, - }); - }); - it("keeps a saved custom provider model available as an exact slug option", () => { - const options = getAppModelOptions("claudeAgent", ["claude/custom-opus"], "claude/custom-opus"); - - expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( - true, - ); - }); -}); - -describe("resolveAppModelSelection", () => { - it("preserves saved custom model slugs instead of falling back to the default", () => { - expect( - resolveAppModelSelection( - "codex", - { codex: ["galapagos-alpha"], claudeAgent: [] }, - "galapagos-alpha", - ), - ).toBe("galapagos-alpha"); - }); - - it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); - }); - - it("resolves display names through the shared resolver", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( - "gpt-5.3-codex", - ); - }); - - it("resolves aliases through the shared resolver", () => { - expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( - "claude-sonnet-4-6", - ); - }); - - it("resolves transient selected custom models included in app model options", () => { - expect( - resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), - ).toBe("custom/selected-model"); - }); -}); - -describe("timestamp format defaults", () => { - it("defaults timestamp format to locale", () => { - expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); - }); -}); - -describe("sidebar sort defaults", () => { - it("defaults project sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_PROJECT_SORT_ORDER).toBe("updated_at"); - }); - - it("defaults thread sorting to updated_at", () => { - expect(DEFAULT_SIDEBAR_THREAD_SORT_ORDER).toBe("updated_at"); - }); -}); - -describe("provider-specific custom models", () => { - it("includes provider-specific custom slugs in non-codex model lists", () => { - const claudeOptions = getAppModelOptions("claudeAgent", ["claude/custom-opus"]); - - expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); - }); -}); - -describe("provider-indexed custom model settings", () => { - const settings = { - customCodexModels: ["custom/codex-model"], - customClaudeModels: ["claude/custom-opus"], - } as const; - - it("exports one provider config per provider", () => { - expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ - "codex", - "claudeAgent", - ]); - }); - - it("reads custom models for each provider", () => { - expect(getCustomModelsForProvider(settings, "codex")).toEqual(["custom/codex-model"]); - expect(getCustomModelsForProvider(settings, "claudeAgent")).toEqual(["claude/custom-opus"]); - }); - - it("reads default custom models for each provider", () => { - const defaults = { - customCodexModels: ["default/codex-model"], - customClaudeModels: ["claude/default-opus"], - } as const; - - expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); - expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ - "claude/default-opus", - ]); - }); - - it("patches custom models for codex", () => { - expect(patchCustomModels("codex", ["custom/codex-model"])).toEqual({ - customCodexModels: ["custom/codex-model"], - }); - }); - - it("patches custom models for claude", () => { - expect(patchCustomModels("claudeAgent", ["claude/custom-opus"])).toEqual({ - customClaudeModels: ["claude/custom-opus"], - }); - }); - - it("builds a complete provider-indexed custom model record", () => { - expect(getCustomModelsByProvider(settings)).toEqual({ - codex: ["custom/codex-model"], - claudeAgent: ["claude/custom-opus"], - }); - }); - - it("builds provider-indexed model options including custom models", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider(settings); - - expect( - modelOptionsByProvider.codex.some((option) => option.slug === "custom/codex-model"), - ).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude/custom-opus"), - ).toBe(true); - }); - - it("normalizes and deduplicates custom model options per provider", () => { - const modelOptionsByProvider = getCustomModelOptionsByProvider({ - customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], - customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], - }); - - expect( - modelOptionsByProvider.codex.filter((option) => option.slug === "custom/codex-model"), - ).toHaveLength(1); - expect(modelOptionsByProvider.codex.some((option) => option.slug === "gpt-5.4")).toBe(true); - expect( - modelOptionsByProvider.claudeAgent.filter((option) => option.slug === "claude/custom-opus"), - ).toHaveLength(1); - expect( - modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), - ).toBe(true); - }); -}); - -describe("resolveAppModelSelectionState", () => { - it("falls back to the default git-writing codex selection", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: [], - textGenerationModelSelection: undefined, - }), - ).toEqual({ - provider: "codex", - model: "gpt-5.4-mini", - }); - }); - - it("preserves the selected provider and resolves saved custom models", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: ["claude/custom-haiku"], - textGenerationModelSelection: { - provider: "claudeAgent", - model: "claude/custom-haiku", - }, - }), - ).toEqual({ - provider: "claudeAgent", - model: "claude/custom-haiku", - }); - }); - - it("normalizes provider options against the resolved model capabilities", () => { - expect( - resolveAppModelSelectionState({ - customCodexModels: [], - customClaudeModels: [], - textGenerationModelSelection: { - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - effort: "max", - thinking: false, - fastMode: true, - }, - }, - }), - ).toEqual({ - provider: "claudeAgent", - model: "claude-haiku-4-5", - options: { - thinking: false, - }, - }); - }); -}); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 3594efbdc5..1ace2d88a7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -12,6 +12,7 @@ import { WS_CHANNELS, WS_METHODS, OrchestrationSessionStatus, + DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; @@ -30,6 +31,7 @@ import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; import { estimateTimelineMessageHeight } from "./timelineHeight"; +import { DEFAULT_CLIENT_SETTINGS } from "~/clientSettings"; const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -118,13 +120,8 @@ function createBaseServerConfig(): ServerConfig { ], availableEditors: [], settings: { - claude: { binaryPath: "" }, - codex: { binaryPath: "", homePath: "" }, - enableAssistantStreaming: false, - defaultThreadEnvMode: "local" as const, - customCodexModels: [], - customClaudeModels: [], - textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, }, }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6fb6cea214..dc5e3ef3ed 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -121,11 +121,7 @@ import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { useAppSettings } from "../appSettings"; -import { - getCustomModelOptionsByProvider, - getCustomModelsByProvider, - resolveAppModelSelection, -} from "../modelSelection"; +import { getCustomModelOptionsByProvider, resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, @@ -610,13 +606,12 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; - const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, selectedProvider, threadModelSelection: activeThread?.modelSelection, projectModelSelection: activeProject?.defaultModelSelection, - customModelsByProvider, + settings, }); const composerProviderState = useMemo( () => @@ -3100,7 +3095,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } - const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); + const resolvedModel = resolveAppModelSelection(provider, settings, model); const nextModelSelection: ModelSelection = { provider, model: resolvedModel, @@ -3115,7 +3110,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, - customModelsByProvider, + settings, ], ); const setPromptFromTraits = useCallback( diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 8d674a2a90..c2ff2b1467 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -54,13 +54,13 @@ function createBaseServerConfig(): ServerConfig { ], availableEditors: [], settings: { - claude: { binaryPath: "" }, - codex: { binaryPath: "", homePath: "" }, enableAssistantStreaming: false, defaultThreadEnvMode: "local" as const, - customCodexModels: [], - customClaudeModels: [], textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, + providers: { + codex: { binaryPath: "", homePath: "", customModels: [] }, + claudeAgent: { binaryPath: "", customModels: [] }, + }, }, }; } diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 61697c944a..56bac1be70 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -5,6 +5,7 @@ import { ClaudeModelOptions, CodexModelOptions, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_SERVER_SETTINGS, ProjectId, ThreadId, } from "@t3tools/contracts"; @@ -21,6 +22,7 @@ import { useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; +import { DEFAULT_CLIENT_SETTINGS } from "~/clientSettings"; // ── Claude TraitsPicker tests ───────────────────────────────────────── @@ -38,7 +40,10 @@ function ClaudeTraitsPickerHarness(props: { selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [] }, + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index a4acd810e7..7cd40f4b18 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -31,6 +31,7 @@ import { import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; +import { UnifiedSettings } from "./appSettings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 3; @@ -616,7 +617,7 @@ export function deriveEffectiveComposerModelState(input: { selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; - customModelsByProvider: Record; + settings: UnifiedSettings; }): EffectiveComposerModelState { const baseModel = resolveModelSlugForProvider( input.selectedProvider, @@ -626,11 +627,7 @@ export function deriveEffectiveComposerModelState(input: { ); const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider]; const selectedModel = activeSelection?.model - ? resolveAppModelSelection( - input.selectedProvider, - input.customModelsByProvider, - activeSelection.model, - ) + ? resolveAppModelSelection(input.selectedProvider, input.settings, activeSelection.model) : baseModel; const modelOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? @@ -2160,7 +2157,7 @@ export function useEffectiveComposerModelState(input: { selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; - customModelsByProvider: Record; + settings: UnifiedSettings; }): EffectiveComposerModelState { const draft = useComposerThreadDraft(input.threadId); @@ -2171,11 +2168,11 @@ export function useEffectiveComposerModelState(input: { selectedProvider: input.selectedProvider, threadModelSelection: input.threadModelSelection, projectModelSelection: input.projectModelSelection, - customModelsByProvider: input.customModelsByProvider, + settings: input.settings, }), [ draft, - input.customModelsByProvider, + input.settings, input.projectModelSelection, input.selectedProvider, input.threadModelSelection, diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 9534170b1f..28859bc6cd 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -10,19 +10,13 @@ import { resolveSelectableModel, } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; +import { UnifiedSettings } from "./appSettings"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export type CustomModelSettings = { - customCodexModels: readonly string[]; - customClaudeModels: readonly string[]; -}; - export type ProviderCustomModelConfig = { provider: ProviderKind; - settingsKey: keyof CustomModelSettings; - defaultSettingsKey: keyof CustomModelSettings; title: string; description: string; placeholder: string; @@ -38,8 +32,6 @@ export interface AppModelOption { const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { codex: { provider: "codex", - settingsKey: "customCodexModels", - defaultSettingsKey: "customCodexModels", title: "Codex", description: "Save additional Codex model slugs for the picker and `/model` command.", placeholder: "your-codex-model-slug", @@ -47,8 +39,6 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record { - return { - [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, - }; -} - -export function getCustomModelsByProvider( - settings: CustomModelSettings, -): Record { - return { - codex: getCustomModelsForProvider(settings, "codex"), - claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), - }; -} - export function getAppModelOptions( + settings: UnifiedSettings, provider: ProviderKind, - customModels: readonly string[], selectedModel?: string | null, ): AppModelOption[] { const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ @@ -137,6 +95,7 @@ export function getAppModelOptions( const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); + const customModels = settings.providers[provider].customModels; for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { continue; @@ -171,57 +130,51 @@ export function getAppModelOptions( export function resolveAppModelSelection( provider: ProviderKind, - customModels: Record, + settings: UnifiedSettings, + selectedModel: string | null | undefined, ): string { - const customModelsForProvider = customModels[provider]; - const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); + const options = getAppModelOptions(settings, provider, selectedModel); return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); } export function getCustomModelOptionsByProvider( - settings: CustomModelSettings, + settings: UnifiedSettings, selectedProvider?: ProviderKind | null, selectedModel?: string | null, ): Record> { - const customModelsByProvider = getCustomModelsByProvider(settings); return { codex: getAppModelOptions( + settings, "codex", - customModelsByProvider.codex, selectedProvider === "codex" ? selectedModel : undefined, ), claudeAgent: getAppModelOptions( + settings, "claudeAgent", - customModelsByProvider.claudeAgent, selectedProvider === "claudeAgent" ? selectedModel : undefined, ), }; } -export function resolveAppModelSelectionState( - settings: CustomModelSettings & { - textGenerationModelSelection: ModelSelection | undefined; - }, -): ModelSelection { +export function resolveAppModelSelectionState(settings: UnifiedSettings): ModelSelection { const selection = settings.textGenerationModelSelection ?? { provider: "codex" as const, model: DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, }; - const provider = selection.provider; - const customModelsByProvider = getCustomModelsByProvider(settings); - const model = resolveAppModelSelection(provider, customModelsByProvider, selection.model); + + const model = resolveAppModelSelection(selection.provider, settings, selection.model); const { modelOptionsForDispatch } = getComposerProviderState({ - provider, + provider: selection.provider, model, prompt: "", modelOptions: { - [provider]: selection.options, + [selection.provider]: selection.options, }, }); return { - provider, + provider: selection.provider, model, ...(modelOptionsForDispatch ? { options: modelOptionsForDispatch } : {}), }; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 38e324dac1..98da7583a6 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -7,10 +7,8 @@ import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { useAppSettings } from "../appSettings"; import { getCustomModelOptionsByProvider, - getCustomModelsForProvider, MAX_CUSTOM_MODEL_LENGTH, MODEL_PROVIDER_SETTINGS, - patchCustomModels, resolveAppModelSelectionState, } from "../modelSelection"; import { APP_VERSION } from "../branding"; @@ -61,11 +59,9 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; -type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; type InstallProviderSettings = { provider: ProviderKind; title: string; - binaryPathKey: InstallBinarySettingsKey; binaryPlaceholder: string; binaryDescription: ReactNode; homePathKey?: "codexHomePath"; @@ -77,7 +73,6 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "codex", title: "Codex", - binaryPathKey: "codexBinaryPath", binaryPlaceholder: "Codex binary path", binaryDescription: ( <> @@ -91,7 +86,6 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "claudeAgent", title: "Claude", - binaryPathKey: "claudeBinaryPath", binaryPlaceholder: "Claude binary path", binaryDescription: ( <> @@ -195,8 +189,8 @@ function SettingsRouteView() { const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); const [openInstallProviders, setOpenInstallProviders] = useState>({ - codex: Boolean(settings.codex?.binaryPath || settings.codex?.homePath), - claudeAgent: Boolean(settings.claude?.binaryPath), + codex: Boolean(settings.providers.codex.binaryPath || settings.providers.codex.homePath), + claudeAgent: Boolean(settings.providers.claudeAgent.binaryPath), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); @@ -211,9 +205,7 @@ function SettingsRouteView() { >({}); const [showAllCustomModels, setShowAllCustomModels] = useState(false); - const codexBinaryPath = settings.codex?.binaryPath ?? ""; - const codexHomePath = settings.codex?.homePath ?? ""; - const claudeBinaryPath = settings.claude?.binaryPath ?? ""; + const codexHomePath = settings.providers.codex.homePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -231,9 +223,11 @@ function SettingsRouteView() { )!; const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; - const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; + const totalCustomModels = + settings.providers.codex.customModels.length + + settings.providers.claudeAgent.customModels.length; const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => - getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ + settings.providers[providerSettings.provider].customModels.map((slug) => ({ key: `${providerSettings.provider}:${slug}`, provider: providerSettings.provider, providerTitle: providerSettings.title, @@ -244,9 +238,9 @@ function SettingsRouteView() { ? savedCustomModelRows : savedCustomModelRows.slice(0, 5); const isInstallSettingsDirty = - settings.claude?.binaryPath !== defaults.claude?.binaryPath || - settings.codex?.binaryPath !== defaults.codex?.binaryPath || - settings.codex?.homePath !== defaults.codex?.homePath; + settings.providers.claudeAgent.binaryPath !== defaults.providers.claudeAgent.binaryPath || + settings.providers.codex.binaryPath !== defaults.providers.codex.binaryPath || + settings.providers.codex.homePath !== defaults.providers.codex.homePath; const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), @@ -262,7 +256,8 @@ function SettingsRouteView() { JSON.stringify(defaults.textGenerationModelSelection ?? null) ? ["Git writing model"] : []), - ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 + ...(settings.providers.codex.customModels.length > 0 || + settings.providers.claudeAgent.customModels.length > 0 ? ["Custom models"] : []), ...(isInstallSettingsDirty ? ["Provider installs"] : []), @@ -294,7 +289,7 @@ function SettingsRouteView() { const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; - const customModels = getCustomModelsForProvider(settings, provider); + const customModels = settings.providers[provider].customModels; const normalized = normalizeModelSlug(customModelInput, provider); if (!normalized) { setCustomModelErrorByProvider((existing) => ({ @@ -325,7 +320,15 @@ function SettingsRouteView() { return; } - updateSettings(patchCustomModels(provider, [...customModels, normalized])); + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: [...customModels, normalized], + }, + }, + }); setCustomModelInputByProvider((existing) => ({ ...existing, [provider]: "", @@ -340,13 +343,16 @@ function SettingsRouteView() { const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { - const customModels = getCustomModelsForProvider(settings, provider); - updateSettings( - patchCustomModels( - provider, - customModels.filter((model) => model !== slug), - ), - ); + const customModels = settings.providers[provider].customModels; + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: customModels.filter((model) => model !== slug), + }, + }, + }); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, @@ -699,8 +705,17 @@ function SettingsRouteView() { label="custom models" onClick={() => { updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, + providers: { + ...settings.providers, + codex: { + ...settings.providers.codex, + customModels: defaults.providers.codex.customModels, + }, + claudeAgent: { + ...settings.providers.claudeAgent, + customModels: defaults.providers.claudeAgent.customModels, + }, + }, }); setCustomModelErrorByProvider({}); setShowAllCustomModels(false); @@ -831,10 +846,17 @@ function SettingsRouteView() { label="provider installs" onClick={() => { updateSettings({ - claude: { binaryPath: defaults.claude?.binaryPath ?? "" }, - codex: { - binaryPath: defaults.codex?.binaryPath ?? "", - homePath: defaults.codex?.homePath ?? "", + providers: { + ...settings.providers, + claudeAgent: { + ...settings.providers.claudeAgent, + binaryPath: defaults.providers.claudeAgent.binaryPath, + }, + codex: { + ...settings.providers.codex, + binaryPath: defaults.providers.codex.binaryPath, + homePath: defaults.providers.codex.homePath, + }, }, }); setOpenInstallProviders({ @@ -852,13 +874,15 @@ function SettingsRouteView() { const isOpen = openInstallProviders[providerSettings.provider]; const isDirty = providerSettings.provider === "codex" - ? settings.codex?.binaryPath !== defaults.codex?.binaryPath || - settings.codex?.homePath !== defaults.codex?.homePath - : settings.claude?.binaryPath !== defaults.claude?.binaryPath; + ? settings.providers.codex.binaryPath !== + defaults.providers.codex.binaryPath || + settings.providers.codex.homePath !== defaults.providers.codex.homePath + : settings.providers.claudeAgent.binaryPath !== + defaults.providers.claudeAgent.binaryPath; const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? (settings.claude?.binaryPath ?? "") - : codexBinaryPath; + providerSettings.provider === "claudeAgent" + ? settings.providers.claudeAgent.binaryPath + : settings.providers.codex.binaryPath; return (