diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6badb4da33ee..f30882ba5115 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1175,7 +1175,11 @@ export function NewTaskDraftScreen(props: { if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; const selectedProject = flow.selectedProject; const draftKey = flow.draftKey; - if (!selectedProject || !draftKey) { + if ( + !selectedProject || + !draftKey || + (environmentConnected && !selectedEnvironmentServerConfig) + ) { return; } const draft = getComposerDraftSnapshot(draftKey); @@ -1301,6 +1305,7 @@ export function NewTaskDraftScreen(props: { // until the write confirms it. clearComposerDraftContent(draftKey, { clearModelSelection: true, + clearRuntimeMode: true, clearWorkspaceSelection: true, deferAttachmentCleanup: true, }); @@ -1337,6 +1342,7 @@ export function NewTaskDraftScreen(props: { !cloneBlocksStart && attachmentBlockReason === null && !modelUnavailable && + (!environmentConnected || selectedEnvironmentServerConfig !== null) && Boolean(flow.selectedProject) && Boolean(flow.selectedModel) && flow.prompt.trim().length > 0 && diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5c74e52cfac1..5a1c67fcd014 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -20,6 +20,7 @@ import { } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/serverSettings"; import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode, @@ -472,11 +473,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; - const defaultRuntimeMode = editingPendingTask - ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) - : projectSettings.settings.defaultRuntimeMode; - const runtimeMode = selectedProjectDraft.runtimeMode ?? defaultRuntimeMode; - // Antigravity keeps unavailable selections so sign-out or a catalog change // cannot switch the user's model. Other providers retain their fallback // rules. Implicit defaults also exclude legacy models for those providers. @@ -515,6 +511,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { stickySelection: stickyModelSelection, modelOptions, }); + const runtimeMode = resolveNewThreadRuntimeMode( + projectSettings.settings, + selectedModel?.instanceId, + selectedProjectDraft.runtimeMode ?? + (editingPendingTask ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) : undefined), + ); const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; @@ -994,7 +996,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { attachments: draft.attachments, context: draft.context, modelSelection: draftModelSelection, - runtimeMode: draft.runtimeMode ?? defaultRuntimeMode, + runtimeMode: resolveNewThreadRuntimeMode( + projectSettings.settings, + draftModelSelection.instanceId, + draft.runtimeMode ?? + (editingPendingTask + ? (editingPendingTask.runtimeMode ?? DEFAULT_RUNTIME_MODE) + : undefined), + ), interactionMode: resolvePendingTaskInteractionMode({ preferenceLoaded: planModePreferenceLoaded, planModeEnabled: legacyPlanModeEnabled, @@ -1030,7 +1039,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }; }, [ - defaultRuntimeMode, + projectSettings.settings, editingPendingProject, editingPendingTask, selectedEnvironmentServerConfig, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 6da72860009e..d09ce89f2c71 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -2,12 +2,15 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { CommandId, ComposerContextId, + DEFAULT_SERVER_SETTINGS, EnvironmentId, MessageId, ProjectId, ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/serverSettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { @@ -173,6 +176,7 @@ import { findLocalComposerClipboardAttachment, flushComposerDrafts, getComposerDraftSnapshot, + mergeComposerDraftContent, mergeComposerDraftContentState, migrateLegacyNewTaskDraft, releaseUnusedComposerAttachmentFiles, @@ -1736,6 +1740,104 @@ describe("mobile composer drafts", () => { ).toThrow(); }); + it.each(["new-task:environment-1:project-1", "new-task:existing-id"])( + "keeps legacy draft %s implicit so project permission defaults still apply", + (key) => { + const draft = { text: "saved under project permissions", attachments: [] }; + const migrated = decodePersistedComposerState({ + schemaVersion: 1, + drafts: { [key]: draft }, + }).drafts; + const restored = Object.values(migrated)[0]!; + expect(restored).toMatchObject(draft); + expect(restored.runtimeMode).toBeUndefined(); + const projectId = ProjectId.make("project-1"); + const settings = resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + defaultRuntimeMode: "full-access", + projectSettingsOverrides: { [projectId]: { defaultRuntimeMode: "approval-required" } }, + }, + projectId, + ).settings; + expect( + resolveNewThreadRuntimeMode( + settings, + ProviderInstanceId.make("codex"), + restored.runtimeMode, + ), + ).toBe("approval-required"); + expect(decodePersistedComposerState({ schemaVersion: 2, drafts: migrated }).drafts).toEqual( + migrated, + ); + }, + ); + + it("preserves separate explicit model choices in current empty drafts", () => { + const first = { + text: "", + attachments: [], + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + } satisfies ComposerDraft; + const second = { + ...first, + modelSelection: { instanceId: ProviderInstanceId.make("claude"), model: "claude-sonnet-4-6" }, + } satisfies ComposerDraft; + const drafts = { "new-task:first": first, "new-task:second": second }; + const restored = decodePersistedComposerState({ + schemaVersion: 2, + drafts, + stickyModelSelection: second.modelSelection, + }); + expect(restored.drafts).toEqual(drafts); + expect(restored.stickyModelSelection).toEqual(second.modelSelection); + }); + + it("keeps current new-task drafts implicit across reloads", () => { + const draft = { text: "keep drafting", attachments: [] } satisfies ComposerDraft; + + expect( + decodePersistedComposerState({ + schemaVersion: 2, + drafts: { "new-task:current-id": draft }, + }).drafts, + ).toEqual({ "new-task:current-id": draft }); + }); + + it("keeps signed-out legacy draft selections unchanged", () => { + const draft = { + text: "", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + } satisfies ComposerDraft; + + expect( + Object.values( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + signedOutDrafts: { + "account-a": { + drafts: { "new-task:environment-1:project-1": draft }, + queuedMessages: [], + }, + }, + }).cloudDrafts.signedOut["account-a"]?.drafts ?? {}, + ), + ).toEqual([ + expect.objectContaining({ + ...draft, + project: expect.objectContaining({ + environmentId: "environment-1", + projectId: "project-1", + }), + }), + ]); + }); + it("keeps share-import receipts on otherwise contentless new-task drafts", () => { const receiptDraft: ComposerDraft = { text: "", @@ -1766,7 +1868,8 @@ describe("mobile composer drafts", () => { importedShareIds: ["share-1"], project: { environmentId: "environment-1", projectId: "project-1" }, }); - expect(stripped[0]?.modelSelection).toBeUndefined(); + expect(stripped[0]?.runtimeMode).toBeUndefined(); + expect(stripped[0]?.modelSelection).toEqual({ instanceId: "codex", model: "gpt-5.4" }); const kept = Object.values( decodePersistedComposerState({ @@ -1776,8 +1879,57 @@ describe("mobile composer drafts", () => { ); expect(kept).toHaveLength(1); expect(kept[0]).toMatchObject(receiptDraft); + expect(kept[0]?.runtimeMode).toBeUndefined(); }); + it("persists share-import receipts on otherwise contentless drafts", async () => { + const draftKey = "new-task:current-id"; + await mergeComposerDraftContent(draftKey, { + text: "", + attachments: [], + sourceShareId: "share-1", + }); + expect(JSON.parse(composerDraftFileMocks.getDocument()).drafts[draftKey]).toEqual({ + text: "", + attachments: [], + importedShareIds: ["share-1"], + }); + }); + + it.each([1, 2])( + "restores archived draft permissions from schema version %s", + async (schemaVersion) => { + const project = { + environmentId: EnvironmentId.make("environment-1"), + projectId: ProjectId.make("project-1"), + createdAt: "2026-09-05T12:00:00.000Z", + }; + composerDraftFileMocks.setDocument({ + schemaVersion, + drafts: {}, + signedOutDrafts: { + "account-1": { + drafts: { + "new-task:implicit-id": { text: "archived", attachments: [], project }, + "new-task:explicit-id": { + text: "configured", + attachments: [], + project, + runtimeMode: "approval-required", + }, + }, + queuedMessages: [], + }, + }, + }); + await restoreCloudComposerDrafts("account-1"); + expect(getComposerDraftSnapshot("new-task:implicit-id").runtimeMode).toBeUndefined(); + expect(getComposerDraftSnapshot("new-task:explicit-id").runtimeMode).toBe( + "approval-required", + ); + }, + ); + it("migrates archived signed-out new-task drafts the same way as live ones", () => { const decoded = decodePersistedComposerState({ schemaVersion: 1, @@ -1919,7 +2071,7 @@ describe("mobile composer drafts", () => { it("waits for hydration before persisting the latest composer state", async () => { vi.useFakeTimers(); composerDraftFileMocks.setDocument({ - schemaVersion: 1, + schemaVersion: 2, drafts: { "environment-1:thread-1": DRAFT, }, @@ -1945,7 +2097,7 @@ describe("mobile composer drafts", () => { await vi.runAllTimersAsync(); expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({ - schemaVersion: 1, + schemaVersion: 2, drafts: { "environment-1:thread-1": DRAFT, "new-task:environment-1:project-1": { @@ -2093,7 +2245,7 @@ describe("mobile composer drafts", () => { await bothWritesCommitted; expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ - schemaVersion: 1, + schemaVersion: 2, drafts: { "environment-2:thread-2": { text: "keep", attachments: [] }, }, @@ -2147,11 +2299,13 @@ describe("mobile composer drafts", () => { worktreePath: null, startFromOrigin: false, }, + runtimeMode: "approval-required", }; expect( clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { clearModelSelection: true, + clearRuntimeMode: true, clearWorkspaceSelection: true, }), ).toEqual({}); @@ -2408,7 +2562,7 @@ describe("mobile composer drafts", () => { await undoComposerDraftMerge(draftKey, snapshot, merged); expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({ - schemaVersion: 1, + schemaVersion: 2, drafts: { [draftKey]: snapshot }, stickyModelSelection: { instanceId: "codex", diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 734199dcef5e..55baf86a1536 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -54,7 +54,7 @@ import { import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; -const COMPOSER_DRAFTS_SCHEMA_VERSION = 1; +const COMPOSER_DRAFTS_SCHEMA_VERSION = 2; const COMPOSER_DRAFTS_DIRECTORY = "composer-drafts"; const COMPOSER_DRAFTS_FILE = "drafts.json"; const PERSIST_DEBOUNCE_MS = 200; @@ -393,7 +393,7 @@ const ComposerDraftSchema = Schema.Struct({ }); const PersistedComposerDraftsSchema = Schema.Struct({ - schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), + schemaVersion: Schema.Literals([1, COMPOSER_DRAFTS_SCHEMA_VERSION]), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), cloudAccountId: Schema.optional(Schema.String), @@ -547,6 +547,41 @@ function isEmptyDraft(draft: ComposerDraft): boolean { ); } +function normalizePersistedDrafts( + drafts: Record, + legacyModelSelection: boolean, + now: string, +): Record { + return Object.fromEntries( + Object.entries(drafts) + .map(([key, draft]) => { + // Stale new-task drafts left on disk by builds before the + // model-precedence fix carry a bare modelSelection with no + // other selector settings. Strip it so the next compose pass + // re-resolves project → sticky → provider defaults. Drafts + // with runtime/interaction/workspace settings or actual text / + // attachments were deliberately configured and are left alone. + const normalized = + legacyModelSelection && + key.startsWith("new-task:") && + draft.modelSelection && + draft.text.length === 0 && + draft.attachments.length === 0 && + (draft.importedShareIds?.length ?? 0) === 0 && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ? { ...draft, modelSelection: undefined } + : draft; + return migrateLegacyNewTaskDraft(key, normalized, now); + }) + // importedShareIds are share-import receipts: a contentless draft + // carrying one is not empty, or the same native share would be + // re-imported after restart. + .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), + ); +} + /** * Writes a draft back, dropping it once empty. A new-task draft keeps its * entry while the composer is bound to it (the project stamp is what the @@ -610,34 +645,7 @@ export function decodePersistedComposerState(value: unknown): { const parsed = decodePersistedComposerDraftsDocument(value); const now = new Date().toISOString(); return { - drafts: Object.fromEntries( - Object.entries(parsed.drafts) - .map(([key, draft]) => - migrateLegacyNewTaskDraft( - key, - // Stale new-task drafts left on disk by builds before the - // model-precedence fix carry a bare modelSelection with no - // other selector settings. Strip it so the next compose pass - // re-resolves project → sticky → provider defaults. Drafts - // with runtime/interaction/workspace settings or actual text / - // attachments were deliberately configured and are left alone. - isNewTaskDraftKey(key) && - draft.modelSelection && - draft.text.length === 0 && - draft.attachments.length === 0 && - draft.runtimeMode === undefined && - draft.interactionMode === undefined && - draft.workspaceSelection === undefined - ? { ...draft, modelSelection: undefined } - : draft, - now, - ), - ) - // importedShareIds are share-import receipts: a contentless draft - // carrying one is not empty, or the same native share would be - // re-imported after restart. - .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), - ), + drafts: normalizePersistedDrafts(parsed.drafts, parsed.schemaVersion === 1, now), stickyModelSelection: parsed.stickyModelSelection ?? null, cloudDrafts: { accountId: parsed.cloudAccountId ?? null, @@ -646,7 +654,7 @@ export function decodePersistedComposerState(value: unknown): { id, { // Archived drafts come back through restoreCloudComposerDrafts - // without another decode, so they get the same key migration. + // without another decode, so their project identity must migrate here. drafts: Object.fromEntries( Object.entries(saved.drafts).map(([key, draft]) => migrateLegacyNewTaskDraft(key, draft, now), @@ -704,7 +712,9 @@ async function writePersistedComposerState( const file = await getComposerDraftsFile(); operation = "encode"; const nonEmptyDrafts = Object.fromEntries( - Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), + Object.entries(drafts).filter( + ([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0, + ), ); const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, @@ -1471,6 +1481,7 @@ export function clearComposerDraftContentState( draftKey: string, options?: { readonly clearModelSelection?: boolean; + readonly clearRuntimeMode?: boolean; readonly clearWorkspaceSelection?: boolean; }, ): Record { @@ -1485,6 +1496,7 @@ export function clearComposerDraftContentState( importedShareIds: _importedShareIds, context: _context, modelSelection, + runtimeMode, workspaceSelection, project: _project, ...retained @@ -1492,6 +1504,7 @@ export function clearComposerDraftContentState( const draft = { ...retained, ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), + ...(options?.clearRuntimeMode || runtimeMode === undefined ? {} : { runtimeMode }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), @@ -1763,6 +1776,7 @@ export function clearComposerDraftContent( draftKey: string, options?: { readonly clearModelSelection?: boolean; + readonly clearRuntimeMode?: boolean; readonly clearWorkspaceSelection?: boolean; // Send clears the draft while the durable outbox write is still in // flight. Sweeping then would race the write: a failed enqueue rolls the diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 4a8aca46bdda..3d5712709771 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -7,6 +7,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -157,7 +158,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provide(ServerSettings.layerTest()), + Effect.provide( + ServerSettings.layerTest({ + providerRuntimeModeDefaults: { + [ProviderInstanceId.make("codex")]: "auto", + }, + }), + ), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -228,6 +235,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa it.effect.each([ { + providerMode: null, existing: false, machineModel: null, projectModel: null, @@ -235,6 +243,7 @@ it.effect.each([ projectMode: null, }, { + providerMode: null, existing: false, machineModel: "claude-sonnet-4-6", projectModel: null, @@ -242,6 +251,7 @@ it.effect.each([ projectMode: null, }, { + providerMode: null, existing: true, machineModel: "claude-sonnet-4-6", projectModel: null, @@ -249,6 +259,23 @@ it.effect.each([ projectMode: null, }, { + providerMode: null, + existing: true, + machineModel: "claude-sonnet-4-6", + projectModel: "gpt-5.4", + machineMode: "full-access", + projectMode: "auto-accept-edits", + }, + { + providerMode: "auto", + existing: false, + machineModel: "claude-sonnet-4-6", + projectModel: null, + machineMode: "full-access", + projectMode: null, + }, + { + providerMode: "approval-required", existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4", @@ -257,7 +284,8 @@ it.effect.each([ }, ] as const)("auto-bootstrap model and permissions precedence: %j", (options) => Effect.gen(function* () { - const { existing, machineModel, projectModel, machineMode, projectMode } = options; + const { existing, machineModel, projectModel, machineMode, projectMode, providerMode } = + options; const machineSelection = machineModel ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } : null; @@ -277,6 +305,12 @@ it.effect.each([ ServerSettings.layerTest({ defaultModelSelection: machineSelection, defaultRuntimeMode: machineMode, + providerRuntimeModeDefaults: providerMode + ? { + [projectSelection?.instanceId ?? machineSelection?.instanceId ?? "codex"]: + providerMode, + } + : {}, projectSettingsOverrides: existing && projectSelection ? { @@ -355,7 +389,7 @@ it.effect.each([ existing ? ["thread.create"] : ["project.create", "thread.create"], ); if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); - assert.equal(commands.at(-1)?.runtimeMode, projectMode ?? machineMode); + assert.equal(commands.at(-1)?.runtimeMode, providerMode ?? projectMode ?? machineMode); assert.deepStrictEqual( commands.at(-1)?.modelSelection, projectSelection ?? @@ -368,12 +402,19 @@ it.effect.each([ ); it.effect( - "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure", + "resolveAutoBootstrapWelcomeTargets preserves project creation when settings and thread lookup fail", () => Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provide(ServerSettings.layerTest()), + Effect.provideService(ServerSettings.ServerSettingsService, { + start: Effect.void, + ready: Effect.void, + getSettings: Effect.die("settings unavailable"), + updateSettings: () => Effect.die("unused"), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + } satisfies ServerSettings.ServerSettingsService["Service"]), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -425,6 +466,34 @@ it.effect( }), ); +it.effect("resolveAutoBootstrapWelcomeTargets propagates settings read interruption", () => + Effect.gen(function* () { + const interrupted = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerSettings.ServerSettingsService, { + start: Effect.void, + ready: Effect.void, + getSettings: Effect.interrupt, + updateSettings: () => Effect.die("unused"), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + } satisfies ServerSettings.ServerSettingsService["Service"]), + Effect.provideService(ServerConfig.ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, {} as never), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {} as never), + Effect.provide(NodeServices.layer), + Effect.matchCause({ + onFailure: Cause.hasInterrupts, + onSuccess: () => false, + }), + ); + + assert.isTrue(interrupted); + }), +); + it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1468e1efecb0..8d147200f8c4 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -15,6 +15,7 @@ import { WorktreeSetupSnapshot, worktreeSetupActivityId, } from "@t3tools/contracts"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/serverSettings"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; @@ -205,7 +206,15 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { - const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings.pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to load settings for automatic bootstrap", { cause }).pipe( + Effect.as(DEFAULT_SERVER_SETTINGS), + ), + ), + ); const defaultModelSelection = settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); yield* Effect.gen(function* () { @@ -252,8 +261,10 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { title: "New thread", modelSelection: nextThreadModelSelection, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: resolveProjectSettings(settings, nextProjectId).settings - .defaultRuntimeMode, + runtimeMode: resolveNewThreadRuntimeMode( + resolveProjectSettings(settings, nextProjectId).settings, + nextThreadModelSelection.instanceId, + ), branch: null, worktreePath: null, createdAt, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 708809712917..bbc143c3182f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,6 +21,7 @@ import { type ApprovalRequestId, type ChatFileAttachment, DEFAULT_MODEL, + DEFAULT_SERVER_SETTINGS, type EnvironmentId, type MessageId, type ModelSelection, @@ -72,6 +73,7 @@ import { projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/serverSettings"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; @@ -250,6 +252,7 @@ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveDefaultProviderModelSelection, sortProviderInstanceEntries, } from "../providerInstances"; import { @@ -1897,12 +1900,14 @@ export default function ChatView(props: ChatViewProps) { // session.lastError. Bump a tick so the banner hides immediately. Mirrors // the branch mismatch banner. const [, setThreadErrorBannerDismissTick] = useState(0); - const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null) - .settings.defaultRuntimeMode; - // Implicit drafts follow their current project/environment, including retargets. - // Explicit composer choices and existing server threads retain their permissions. - const runtimeMode = composerRuntimeMode ?? activeServerThread?.runtimeMode ?? defaultRuntimeMode; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; + const runtimeMode = + composerRuntimeMode ?? + activeServerThread?.runtimeMode ?? + resolveNewThreadRuntimeMode( + resolveProjectSettings(settings, activeThread?.projectId ?? null).settings, + composerActiveProvider ?? activeThread?.modelSelection.instanceId, + ); const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; @@ -2490,11 +2495,21 @@ export default function ChatView(props: ChatViewProps) { const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); + const targetServerConfig = environmentById.get(activeProject.environmentId)?.serverConfig; + const projectSettings = resolveProjectSettings( + settings, + activeProject.id, + activeProject, + ).settings; setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings - .defaultRuntimeMode, + runtimeMode: resolveNewThreadRuntimeMode( + projectSettings, + projectSettings.defaultModelSelection?.instanceId ?? + resolveDefaultProviderModelSelection(targetServerConfig?.providers ?? [], null) + ?.instanceId, + ), interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); @@ -2507,6 +2522,7 @@ export default function ChatView(props: ChatViewProps) { [ activeProject, draftId, + environmentById, getDraftSession, getDraftSessionByLogicalProjectKey, isServerThread, @@ -3797,16 +3813,40 @@ export default function ChatView(props: ChatViewProps) { (environment) => environment.environmentId === loadBalancing.environmentId, ); if (!target) return; + const targetProject = allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ); + const targetServerConfig = environmentById.get(target.environmentId)?.serverConfig; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), environmentSelection: "auto", loadBalancedEnvironmentId: target.environmentId, + ...(composerRuntimeMode === null + ? { + runtimeMode: resolveNewThreadRuntimeMode( + resolveProjectSettings( + targetServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + target.projectId, + targetProject, + ).settings, + composerActiveProvider ?? + targetProject?.defaultModelSelection?.instanceId ?? + resolveDefaultProviderModelSelection(targetServerConfig?.providers ?? [], null) + ?.instanceId, + ), + } + : {}), }); }, [ + allProjects, + composerActiveProvider, + composerRuntimeMode, needsLoadBalancing, loadBalancing.pending, loadBalancing.environmentId, draftId, + environmentById, logicalProjectEnvironments, setDraftThreadContext, ]); @@ -3859,13 +3899,42 @@ export default function ChatView(props: ChatViewProps) { (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; + const targetProject = allProjects.find( + (project) => + project.environmentId === target.environmentId && project.id === target.projectId, + ); + const targetServerConfig = environmentById.get(target.environmentId)?.serverConfig; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), environmentSelection: "manual", loadBalancedEnvironmentId: null, + ...(composerRuntimeMode === null + ? { + runtimeMode: resolveNewThreadRuntimeMode( + resolveProjectSettings( + targetServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + target.projectId, + targetProject, + ).settings, + composerActiveProvider ?? + targetProject?.defaultModelSelection?.instanceId ?? + resolveDefaultProviderModelSelection(targetServerConfig?.providers ?? [], null) + ?.instanceId, + ), + } + : {}), }); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [ + allProjects, + composerActiveProvider, + composerRuntimeMode, + draftId, + envLocked, + environmentById, + logicalProjectEnvironments, + setDraftThreadContext, + ], ); const activeTerminalGroup = @@ -4426,7 +4495,7 @@ export default function ChatView(props: ChatViewProps) { const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { - if (mode === runtimeMode) return; + if (mode === composerRuntimeMode) return; setComposerDraftRuntimeMode(composerDraftTarget, mode); if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); @@ -4435,7 +4504,7 @@ export default function ChatView(props: ChatViewProps) { }, [ isLocalDraftThread, - runtimeMode, + composerRuntimeMode, scheduleComposerFocus, composerDraftTarget, setComposerDraftRuntimeMode, @@ -8702,6 +8771,10 @@ export default function ChatView(props: ChatViewProps) { } const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; + const nextThreadRuntimeMode = resolveNewThreadRuntimeMode( + resolveProjectSettings(settings, activeProject.id, activeProject).settings, + nextThreadModelSelection.instanceId, + ); sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); @@ -8717,7 +8790,7 @@ export default function ChatView(props: ChatViewProps) { projectId: activeProject.id, title: nextThreadTitle, modelSelection: nextThreadModelSelection, - runtimeMode: defaultRuntimeMode, + runtimeMode: nextThreadRuntimeMode, interactionMode: "default", branch: activeThreadBranch, worktreePath: activeThread.worktreePath, @@ -8740,7 +8813,7 @@ export default function ChatView(props: ChatViewProps) { }, modelSelection: ctxSelectedModelSelection, titleSeed: nextThreadTitle, - runtimeMode: defaultRuntimeMode, + runtimeMode: nextThreadRuntimeMode, interactionMode: "default", sourceProposedPlan: { threadId: activeThread.id, @@ -8809,12 +8882,12 @@ export default function ChatView(props: ChatViewProps) { activeEnvironmentUnavailable, createThread, deleteThread, + settings, isConnecting, isSendBusy, isServerThread, navigate, resetLocalDispatch, - defaultRuntimeMode, startThreadTurn, environmentId, composerRef, @@ -8902,13 +8975,25 @@ export default function ChatView(props: ChatViewProps) { { explicit: true }, ); setStickyComposerModelSelection(nextModelSelection); + if (isLocalDraftThread && composerRuntimeMode === null) { + setDraftThreadContext(composerDraftTarget, { + runtimeMode: resolveNewThreadRuntimeMode( + resolveProjectSettings(settings, activeThread.projectId).settings, + instanceId, + ), + }); + } scheduleComposerFocus(); }, [ activeThread, + composerDraftTarget, + composerRuntimeMode, + isLocalDraftThread, lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, + setDraftThreadContext, setStickyComposerModelSelection, providerStatuses, settings, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 07956c2b299e..d32b8207d4af 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -8,13 +8,15 @@ import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { useClientSettings } from "~/hooks/useSettings"; import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/serverSettings"; import { selectProjectGroupingSettings } from "~/logicalProject"; +import { resolveDefaultProviderModelSelection } from "~/providerInstances"; import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, projectGroupsSpanEnvironments, } from "~/sidebarProjectGrouping"; -import { useProjects, useThreadShells } from "~/state/entities"; +import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { ProjectEnvironmentBadge } from "../ProjectEnvironmentBadge"; import { ProjectFavicon } from "../ProjectFavicon"; @@ -44,6 +46,7 @@ export function DraftHeroHeadline({ }: DraftHeroHeadlineProps) { const projects = useProjects(); const threads = useThreadShells(); + const serverConfigs = useServerConfigs(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -54,6 +57,7 @@ export function DraftHeroHeadline({ const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); const environmentLabelById = useMemo( @@ -170,22 +174,36 @@ export function DraftHeroHeadline({ entry.group.projectKey, scopeProjectRef(project.environmentId, project.id), draftId, + currentDraft?.runtimeMode == null + ? undefined + : { runtimeMode: currentDraft.runtimeMode }, ); + const targetServerConfig = serverConfigs.get(project.environmentId); + const projectSettings = targetServerConfig + ? resolveProjectSettings(targetServerConfig.settings, project.id, project).settings + : undefined; + const defaultModelSelection = projectSettings + ? projectSettings.defaultModelSelection + : project.defaultModelSelection; if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - const environmentSettings = environments.find( - (environment) => environment.environmentId === project.environmentId, - )?.serverConfig?.settings; - const defaultModelSelection = environmentSettings - ? resolveProjectSettings(environmentSettings, project.id, project).settings - .defaultModelSelection - : project.defaultModelSelection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, }); } } + if (currentDraft?.runtimeMode == null) { + setDraftThreadContext(draftId, { + runtimeMode: resolveNewThreadRuntimeMode( + projectSettings, + getComposerDraft(draftId)?.activeProvider ?? + defaultModelSelection?.instanceId ?? + resolveDefaultProviderModelSelection(targetServerConfig?.providers ?? [], null) + ?.instanceId, + ), + }); + } }} > {projectPickerEntries.map(({ group }) => { diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index b1e4c646f872..fc57bc451f7e 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -373,6 +373,7 @@ interface ProviderInstanceCardProps { */ readonly headerAction?: ReactNode | undefined; readonly setup?: ReactNode; + readonly newThreadDefaults?: ReactNode; readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; @@ -415,6 +416,7 @@ export function ProviderInstanceCard({ onDelete, headerAction, setup, + newThreadDefaults, hiddenModels, favoriteModels, modelOrder, @@ -850,6 +852,7 @@ export function ProviderInstanceCard({ aria-disabled={readOnly || undefined} className={readOnly ? "opacity-50 select-none" : undefined} > + {newThreadDefaults} {driverOption ? ( { providerInstances: { [codexId]: settingsState.value.providerInstances?.[codexId], }, + providerRuntimeModeDefaults: { [customId]: null }, }); settingsState.updateSettings.mockClear(); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index f1966fe45474..d8b5ef443e84 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -13,6 +13,7 @@ import { ProviderDriverKind, type ProviderInstanceConfig, type ProviderInstanceId, + type RuntimeMode, resolveEnvironmentMachineKind, resolveProviderInstanceEnabled, } from "@t3tools/contracts"; @@ -75,6 +76,7 @@ import { NumberFieldInput, } from "../ui/number-field"; import { ScrollArea } from "../ui/scroll-area"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -132,6 +134,20 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ provider: definition.value, })); +const RUNTIME_MODE_OPTIONS: ReadonlyArray<{ + readonly value: RuntimeMode; + readonly label: string; +}> = [ + { value: "approval-required", label: "Supervised" }, + { value: "auto-accept-edits", label: "Auto-accept edits" }, + { value: "auto", label: "Auto" }, + { value: "full-access", label: "Full access" }, +]; + +function runtimeModeLabel(mode: RuntimeMode): string { + return RUNTIME_MODE_OPTIONS.find((option) => option.value === mode)?.label ?? mode; +} + function configuredBinaryPath(config: unknown): string { if (config === null || typeof config !== "object" || !("binaryPath" in config)) return ""; return typeof config.binaryPath === "string" ? config.binaryPath.trim() : ""; @@ -817,6 +833,7 @@ export function EnvironmentProviderSettings({ const deleteProviderInstance = (id: ProviderInstanceId) => { updateSettings({ providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), + providerRuntimeModeDefaults: { [id]: null }, }); }; @@ -902,6 +919,12 @@ export function EnvironmentProviderSettings({ favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, ); const resetLabel = driverOption?.label ?? String(row.driver); + const providerRuntimeModeDefault = Object.hasOwn( + settings.providerRuntimeModeDefaults, + row.instanceId, + ) + ? settings.providerRuntimeModeDefaults[row.instanceId] + : undefined; return ( ) : null } + newThreadDefaults={ + mode === "editor" ? ( + { + if (value === "inherit") { + updateSettings({ providerRuntimeModeDefaults: { [row.instanceId]: null } }); + } else if ( + value === "approval-required" || + value === "auto-accept-edits" || + value === "auto" || + value === "full-access" + ) { + updateSettings({ providerRuntimeModeDefaults: { [row.instanceId]: value } }); + } + }} + > + + + {providerRuntimeModeDefault + ? runtimeModeLabel(providerRuntimeModeDefault) + : `Default (${runtimeModeLabel(settings.defaultRuntimeMode)})`} + + + + + Default ({runtimeModeLabel(settings.defaultRuntimeMode)}) + + {RUNTIME_MODE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + } + /> + ) : null + } onUpdate={(next) => { const wasEnabled = resolveProviderInstanceEnabled(row.instance); const isDisabling = next.enabled === false && wasEnabled; @@ -1042,6 +1108,37 @@ export function EnvironmentProviderSettings({ )} + { + if ( + value === "approval-required" || + value === "auto-accept-edits" || + value === "auto" || + value === "full-access" + ) { + updateSettings({ defaultRuntimeMode: value }); + } + }} + disabled={readOnly} + > + + {runtimeModeLabel(settings.defaultRuntimeMode)} + + + {RUNTIME_MODE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + } + /> {readOnly ? (
{ worktreePath: "/tmp/local-worktree", envMode: "worktree", startFromOrigin: true, + runtimeMode: "auto", }); store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), remoteProjectRef, draftId, { @@ -1776,7 +1777,23 @@ describe("composerDraftStore project draft thread mapping", () => { worktreePath: null, envMode: "worktree", startFromOrigin: true, + runtimeMode: "full-access", + }); + }); + + it("keeps an explicit runtime mode when remapping a draft to another environment", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + runtimeMode: "auto", + }); + store.setRuntimeMode(draftId, "approval-required"); + + store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), remoteProjectRef, draftId, { + threadId, }); + + expect(store.getDraftThread(draftId)?.runtimeMode).toBe("approval-required"); }); it("clears stale upload metadata when retargeting a draft to another environment", () => { @@ -2564,11 +2581,13 @@ describe("composerDraftStore model seed migration", () => { modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, activeProvider: CODEX_INSTANCE, }); + expect(draftByKey(typedDraftId)?.runtimeMode).toBeNull(); expect(draftByKey(explicitDraftId)).toMatchObject({ modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, activeProvider: CODEX_INSTANCE, modelSelectionExplicit: true, }); + expect(draftByKey(explicitDraftId)?.runtimeMode).toBeNull(); expect(draftByKey(serverThreadKey)).toMatchObject({ modelSelectionByProvider: { [CODEX_INSTANCE]: staleSelection }, activeProvider: CODEX_INSTANCE, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ce2c51fe5247..6ae735afe052 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1497,6 +1497,7 @@ function createDraftThreadState( threadId: ThreadId, logicalProjectKey: string, existingThread: DraftThreadState | undefined, + explicitRuntimeMode: RuntimeMode | null, options?: { threadId?: ThreadId; branch?: string | null; @@ -1552,7 +1553,12 @@ function createDraftThreadState( } : {}), createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), - runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode: + options?.runtimeMode ?? + explicitRuntimeMode ?? + (projectChanged + ? DEFAULT_RUNTIME_MODE + : (existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE)), interactionMode: options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, branch: nextBranch, @@ -2627,6 +2633,7 @@ const composerDraftStore = create()( options?.threadId ?? existingThread?.threadId ?? ThreadId.make(draftId), normalizedLogicalProjectKey, existingThread, + state.draftsByThreadKey[draftId]?.runtimeMode ?? null, options, ); const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6cae988e87c6..a6e37528b9c7 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -112,6 +112,10 @@ vi.mock("@t3tools/shared/threadEnvMode", () => ({ readonly globalDefault: "local" | "worktree"; }) => input.projectFile ?? input.globalDefault, })); +vi.mock("@t3tools/shared/serverSettings", () => ({ + resolveNewThreadRuntimeMode: (settings: { defaultRuntimeMode?: string }) => + settings.defaultRuntimeMode ?? "full-access", +})); vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, @@ -160,8 +164,10 @@ vi.mock("../state/entities", () => ({ ], readThreadShell: () => null, useProjects: () => [], + useServerConfigs: () => new Map(), useThread: () => null, })); +vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", @@ -226,6 +232,29 @@ describe.each([ expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); + it("re-resolves an implicit reusable draft's runtime mode", async () => { + testState.reset({ + draftId: "draft-existing", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-existing", + }); + const openThread = useNewThreadHandler(); + const pendingOpen = openThread({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith( + "remote-project", + { environmentId: "environment-ssh", projectId: "project-remote" }, + "draft-existing", + expect.objectContaining({ runtimeMode: "full-access" }), + ); + }); it.each([true, false])( "uses the target environment's start-from-origin default of %s", async (startFromOrigin) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 65ac2307ce89..b5d545eaa7b1 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -24,6 +24,7 @@ import { } from "../logicalProject"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/serverSettings"; import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; import { hasExplicitComposerModelSelection, @@ -32,6 +33,7 @@ import { } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; import { environmentServerConfigsAtom } from "../state/server"; +import { resolveDefaultProviderModelSelection } from "../providerInstances"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -81,6 +83,7 @@ export function useNewThreadHandler() { const projects = readProjects(); const targetServerSettings = environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; + const targetServerConfig = environmentServerConfigs.get(projectRef.environmentId); const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -94,10 +97,10 @@ export function useNewThreadHandler() { const requestingRouteHref = router.state.location.href; const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); - // A new thread carries the user's working mode from the thread being - // viewed. The target project's configured model still wins; interaction - // mode carries independently. Permissions, branch, worktree, and env mode - // come from configured defaults unless the caller passes them explicitly. + // The target project's configured model and permission defaults win. + // Interaction mode carries from the thread being viewed. Branch, + // worktree, and env mode come from configured defaults unless the caller + // passes them explicitly. const carrySourceShell = currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) @@ -137,11 +140,23 @@ export function useNewThreadHandler() { project, ); const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; - const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const projectThreadEnvMode = projectSettings.sources.defaultThreadEnvMode === "project" ? projectSettings.settings.defaultThreadEnvMode : undefined; + const resolveImplicitRuntimeMode = (destinationDraftId: DraftId) => { + const destinationDraft = getComposerDraft(destinationDraftId); + if (destinationDraft?.runtimeMode != null) { + return undefined; + } + return resolveNewThreadRuntimeMode( + projectSettings.settings, + destinationDraft?.activeProvider ?? + projectDefaultModelSelection?.instanceId ?? + resolveDefaultProviderModelSelection(targetServerConfig?.providers ?? [], null) + ?.instanceId, + ); + }; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ projectDefaultSelection: projectDefaultModelSelection ?? null, @@ -267,7 +282,6 @@ export function useNewThreadHandler() { if (workspaceContext) { setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, - ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); } @@ -297,14 +311,15 @@ export function useNewThreadHandler() { // targets a different physical member of the logical project, // createDraftThreadState treats the remap as a project change and // would otherwise wipe branch/worktree, undoing the write above. + const implicitRuntimeMode = resolveImplicitRuntimeMode(emptyStoredDraftThread.draftId); setLogicalProjectDraftThreadId( logicalProjectKey, projectRef, emptyStoredDraftThread.draftId, { threadId: emptyStoredDraftThread.threadId, + ...(implicitRuntimeMode ? { runtimeMode: implicitRuntimeMode } : {}), ...workspaceContext, - ...(!isDraftAlreadyOpen ? { runtimeMode: defaultRuntimeMode } : {}), ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); @@ -351,7 +366,9 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { threadId: latestActiveDraftThread.threadId, createdAt: latestActiveDraftThread.createdAt, - runtimeMode: latestActiveDraftThread.runtimeMode, + runtimeMode: + resolveImplicitRuntimeMode(currentRouteTarget.draftId) ?? + latestActiveDraftThread.runtimeMode, interactionMode: latestActiveDraftThread.interactionMode, ...pickExplicitWorkspaceOptions(options), }); @@ -394,7 +411,7 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, racedDraft.draftId, { threadId: racedDraft.threadId, createdAt: racedDraft.createdAt, - runtimeMode: racedDraft.runtimeMode, + runtimeMode: resolveImplicitRuntimeMode(racedDraft.draftId) ?? racedDraft.runtimeMode, interactionMode: racedDraft.interactionMode, ...pickExplicitWorkspaceOptions(options), }); @@ -417,7 +434,7 @@ export function useNewThreadHandler() { envMode: initialEnvMode, newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, }), - runtimeMode: defaultRuntimeMode, + runtimeMode: projectSettings.settings.defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); @@ -427,6 +444,14 @@ export function useNewThreadHandler() { // state. The project default wins when both are present. setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); } + setDraftThreadContext(draftId, { + runtimeMode: resolveNewThreadRuntimeMode( + projectSettings.settings, + getComposerDraft(draftId)?.activeProvider ?? + resolveDefaultProviderModelSelection(targetServerConfig?.providers ?? [], null) + ?.instanceId, + ), + }); await router.navigate({ to: "/draft/$draftId", params: { draftId }, diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index dfc0c448c858..243a6e400346 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -3,10 +3,11 @@ Permission modes control when an agent needs your approval to act. Choose a mode in the message composer; it applies to that thread. -Set the default for new threads in **Settings → General → New threads → Permissions**. -Projects can override the environment default. New threads use this setting rather than the -mode of the thread you were viewing. The initial default is **Full access**; existing threads -and modes you choose in a draft keep their permissions. +Set the fallback for new threads in **Settings → General → New threads → Permissions**. +Projects can override the environment fallback. Each provider instance can override the fallback +in **Settings → Providers → Runtime**. A new draft follows the selected provider instance until +you explicitly pick a mode in the composer; that choice remains fixed when you switch providers. +New threads never inherit the mode of another thread, and existing threads keep their permissions. | Mode | Behavior | | --------------------- | ------------------------------------------------------------------------------------- | diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ba612edaf26b..79a32cdcbba2 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -668,6 +668,33 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("ServerSettings permission defaults", () => { + it("defaults new threads to full access without provider overrides", () => { + const settings = decodeServerSettings({}); + + expect(settings.defaultRuntimeMode).toBe("full-access"); + expect(settings.providerRuntimeModeDefaults).toEqual({}); + }); + + it("accepts per-instance overrides and null removals in patches", () => { + expect( + decodeServerSettingsPatch({ + defaultRuntimeMode: "approval-required", + providerRuntimeModeDefaults: { + codex_work: "auto", + claude_work: null, + }, + }), + ).toMatchObject({ + defaultRuntimeMode: "approval-required", + providerRuntimeModeDefaults: { + codex_work: "auto", + claude_work: null, + }, + }); + }); +}); + describe("provider enabled defaults", () => { it("enables only the stable bindings by default", () => { const decoded = decodeServerSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1c53b36037ba..861040e1827c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1127,6 +1127,9 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + providerRuntimeModeDefaults: Schema.Record(ProviderInstanceId, RuntimeMode).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), @@ -1394,6 +1397,9 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + providerRuntimeModeDefaults: Schema.optionalKey( + Schema.Record(ProviderInstanceId, Schema.NullOr(RuntimeMode)), + ), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 2658db3346e6..28f085abb7cb 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -15,6 +15,7 @@ import { applyServerSettingsPatch, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, + resolveNewThreadRuntimeMode, resolveSourceControlWriterModelSelection, resolveProjectAgentBrowserAccess, resolveProjectAutoPull, @@ -521,6 +522,30 @@ describe("serverSettings helpers", () => { expect(current.usagePriceOverrides["example-model"]?.cacheReadCostPerMillionTokens).toBe(0.5); }); + it("resolves and updates permission defaults per provider instance", () => { + const codexWork = ProviderInstanceId.make("codex-work"); + const configured = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultRuntimeMode: "approval-required", + providerRuntimeModeDefaults: { + [codexWork]: "auto", + }, + }); + + expect(resolveNewThreadRuntimeMode(configured, codexWork)).toBe("auto"); + expect(resolveNewThreadRuntimeMode(configured, ProviderInstanceId.make("claude"))).toBe( + "approval-required", + ); + expect(resolveNewThreadRuntimeMode(configured, codexWork, "full-access")).toBe("full-access"); + expect(resolveNewThreadRuntimeMode(configured, ProviderInstanceId.make("constructor"))).toBe( + "approval-required", + ); + + const cleared = applyServerSettingsPatch(configured, { + providerRuntimeModeDefaults: { [codexWork]: null }, + }); + expect(resolveNewThreadRuntimeMode(cleared, codexWork)).toBe("approval-required"); + }); + it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { backgroundActivity: { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index bb209fd812bd..1671dd1f6163 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,4 +1,5 @@ import { + DEFAULT_RUNTIME_MODE, isProviderDriverKind, isProviderAvailable, resolveProviderInstanceEnabled, @@ -7,6 +8,7 @@ import { type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, type ProviderDriverKind, + type ProviderInstanceId, type ServerProvider, ServerSettings, type ServerSettingsPatch, @@ -173,6 +175,23 @@ function mergeSettingsEntries( return Object.fromEntries(next); } +export function resolveNewThreadRuntimeMode( + settings: + | Pick + | null + | undefined, + instanceId: ProviderInstanceId | null | undefined, + explicitRuntimeMode?: ServerSettings["defaultRuntimeMode"] | null, +): ServerSettings["defaultRuntimeMode"] { + const providerDefault = + instanceId && settings && Object.hasOwn(settings.providerRuntimeModeDefaults, instanceId) + ? settings.providerRuntimeModeDefaults[instanceId] + : undefined; + return ( + explicitRuntimeMode ?? providerDefault ?? settings?.defaultRuntimeMode ?? DEFAULT_RUNTIME_MODE + ); +} + /** * Derived views of `projectSettingsOverrides` for clients that still read * the legacy per-key maps. Recomputed on every patch and load so they @@ -273,6 +292,7 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + providerRuntimeModeDefaults: providerRuntimeModeDefaultsPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. projectSettingsOverrides: projectSettingsOverridesPatch, // Already translated into `projectSettingsOverrides` above; the legacy @@ -367,6 +387,14 @@ export function applyServerSettingsPatch( ), } : {}), + ...(providerRuntimeModeDefaultsPatch !== undefined + ? { + providerRuntimeModeDefaults: mergeSettingsEntries( + current.providerRuntimeModeDefaults, + providerRuntimeModeDefaultsPatch, + ), + } + : {}), ...(patch.sourceControlWriterModelSelection !== undefined ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } : {}),