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 535581b58e75..adb6192d2fd1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -12,7 +12,6 @@ import type { import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, - DEFAULT_RUNTIME_MODE, MessageId, T3_PROJECT_FILE_NAME, ThreadId, @@ -36,6 +35,7 @@ import { resolveNewTaskModelSelection, resolveSelectableModelSelection, } from "../../lib/modelOptions"; +import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; import { projectEnvironment } from "../../state/projects"; @@ -54,10 +54,13 @@ import { scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, setStickyComposerModelSelection, + setStickyComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, useStickyComposerModelSelection, + useStickyComposerRuntimeMode, } from "../../state/use-composer-drafts"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { capturePendingTaskEditorWriteBaseline, flushPendingTaskEditorWrite, @@ -452,7 +455,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { draftStartFromOrigin ?? selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ?? true; - const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; // Antigravity keeps unavailable selections so sign-out or a catalog change // cannot switch the user's model. Other providers retain their fallback @@ -472,6 +474,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, storedStickyModelSelection, ); + // Sticky access mode is keyed by logical project identity (same as web), + // so equivalent repo instances share the preference across grouping modes. + const selectedLogicalProjectKey = selectedProject + ? deriveLogicalProjectKeyFromSettings(selectedProject, groupingSettings) + : null; + const stickyRuntimeMode = useStickyComposerRuntimeMode(selectedLogicalProjectKey); + const runtimeMode = resolveNewThreadRuntimeMode({ + draftRuntimeMode: selectedProjectDraft.runtimeMode, + stickyRuntimeMode, + configuredRuntimeMode: selectedEnvironmentServerConfig?.settings.defaultRuntimeMode, + }); const modelOptions = useMemo( () => buildModelOptions( @@ -875,8 +888,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (selectedProjectDraftKey) { updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); } + if (selectedLogicalProjectKey) { + setStickyComposerRuntimeMode(selectedLogicalProjectKey, value); + } }, - [selectedProjectDraftKey], + [selectedLogicalProjectKey, selectedProjectDraftKey], ); const setInteractionMode = useCallback( (value: ProviderInteractionMode) => { @@ -965,7 +981,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { text, attachments: draft.attachments, modelSelection: draftModelSelection, - runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode, interactionMode: resolvePendingTaskInteractionMode({ preferenceLoaded: planModePreferenceLoaded, planModeEnabled: legacyPlanModeEnabled, @@ -1007,8 +1023,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedProject, selectedProjectDraftKey, + selectedProjectKey, legacyPlanModeEnabled, planModePreferenceLoaded, + runtimeMode, startFromOrigin, workspaceMode, ], diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index c055b515448a..527f5a8c8933 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -178,7 +178,9 @@ import { setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, setStickyComposerModelSelection, + setStickyComposerRuntimeMode, stickyComposerModelSelectionAtom, + stickyComposerRuntimeModeByProjectKeyAtom, undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; @@ -202,6 +204,7 @@ afterEach(() => { appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); + appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, {}); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); composerAttachmentCleanupMocks.releaseUploads.mockReset(); @@ -210,6 +213,47 @@ afterEach(() => { incomingShareStorageMocks.load.mockResolvedValue([]); }); +describe("mobile sticky runtime mode", () => { + it("round-trips sticky runtime modes per project", () => { + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + stickyRuntimeModeByProjectKey: { + "env:project-a": "approval-required", + "env:project-b": "auto-accept-edits", + }, + }).stickyRuntimeModeByProjectKey, + ).toEqual({ + "env:project-a": "approval-required", + "env:project-b": "auto-accept-edits", + }); + }); + + it("reads an intermediate lastUsedRuntimeModeByProjectKey as sticky", () => { + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + lastUsedRuntimeModeByProjectKey: { + "env:project-a": "approval-required", + }, + }).stickyRuntimeModeByProjectKey, + ).toEqual({ + "env:project-a": "approval-required", + }); + }); + + it("stores sticky runtime mode updates in memory", () => { + setStickyComposerRuntimeMode("env:project-a", "approval-required"); + expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({ + "env:project-a": "approval-required", + }); + setStickyComposerRuntimeMode("env:project-a", null); + expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({}); + }); +}); + describe("mobile composer drafts", () => { // Hydration is one-shot per module instance and the attachment sweep now // triggers it too, so this test must observe it before any sweep test runs. diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 37ea3bbe6ee0..7096b32f0ea7 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -127,6 +127,8 @@ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), + stickyRuntimeModeByProjectKey: Schema.optional(Schema.Record(Schema.String, RuntimeModeSchema)), + lastUsedRuntimeModeByProjectKey: Schema.optional(Schema.Record(Schema.String, RuntimeModeSchema)), cloudAccountId: Schema.optional(Schema.String), signedOutDrafts: Schema.optional( Schema.Record( @@ -158,6 +160,10 @@ export const stickyComposerModelSelectionAtom = Atom.make Atom.withLabel("mobile:sticky-composer-model-selection"), ); +export const stickyComposerRuntimeModeByProjectKeyAtom = Atom.make< + Partial> +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-runtime-mode-by-project")); + interface SignedOutDrafts { readonly drafts: Record; readonly queuedMessages: ReadonlyArray; @@ -273,6 +279,7 @@ export function migrateLegacyNewTaskDraft( export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; + readonly stickyRuntimeModeByProjectKey: Partial>; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); @@ -307,6 +314,9 @@ export function decodePersistedComposerState(value: unknown): { .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, + stickyRuntimeModeByProjectKey: normalizeStickyRuntimeModeByProjectKey( + parsed.stickyRuntimeModeByProjectKey ?? parsed.lastUsedRuntimeModeByProjectKey, + ), cloudDrafts: { accountId: parsed.cloudAccountId ?? null, signedOut: Object.fromEntries( @@ -328,6 +338,23 @@ export function decodePersistedComposerState(value: unknown): { }; } +function normalizeStickyRuntimeModeByProjectKey( + value: Partial> | undefined, +): Partial> { + if (!value) { + return {}; + } + const next: Partial> = {}; + for (const [projectKey, runtimeMode] of Object.entries(value)) { + const key = projectKey.trim(); + if (key.length === 0 || runtimeMode === undefined) { + continue; + } + next[key] = runtimeMode; + } + return next; +} + async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); @@ -345,6 +372,7 @@ async function loadPersistedComposerState(): Promise< return { drafts: {}, stickyModelSelection: null, + stickyRuntimeModeByProjectKey: {}, cloudDrafts: { accountId: null, signedOut: {} }, }; } @@ -365,6 +393,9 @@ async function loadPersistedComposerState(): Promise< async function writePersistedComposerState( drafts: Record, stickyModelSelection: ModelSelection | null, + stickyRuntimeModeByProjectKey: Partial> = appAtomRegistry.get( + stickyComposerRuntimeModeByProjectKeyAtom, + ), cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), ): Promise { let operation: ComposerDraftPersistenceError["operation"] = "open"; @@ -374,10 +405,18 @@ async function writePersistedComposerState( const nonEmptyDrafts = Object.fromEntries( Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); + const stickyRuntimeModes = Object.fromEntries( + Object.entries(stickyRuntimeModeByProjectKey).filter( + (entry): entry is [string, RuntimeMode] => entry[1] !== undefined, + ), + ); const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), + ...(Object.keys(stickyRuntimeModes).length > 0 + ? { stickyRuntimeModeByProjectKey: stickyRuntimeModes } + : {}), ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), ...(Object.keys(cloudDrafts.signedOut).length > 0 ? { @@ -629,6 +668,7 @@ function schedulePersistComposerState(): void { await writePersistedComposerState( appAtomRegistry.get(composerDraftsAtom), appAtomRegistry.get(stickyComposerModelSelectionAtom), + appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom), ); persistRetryNeeded = false; } catch (error) { @@ -661,6 +701,13 @@ export function ensureComposerDraftsLoaded(): void { ) { appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); } + if (Object.keys(persisted.stickyRuntimeModeByProjectKey).length > 0) { + const current = appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom); + appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, { + ...persisted.stickyRuntimeModeByProjectKey, + ...current, + }); + } }); loadPromise = loading; // Handle fire-and-forget hook loads without swallowing failures from the @@ -892,6 +939,37 @@ export function setStickyComposerModelSelection(modelSelection: ModelSelection): schedulePersistComposerState(); } +export function getStickyComposerRuntimeMode(projectKey: string): RuntimeMode | null { + const key = projectKey.trim(); + if (key.length === 0) { + return null; + } + return appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)[key] ?? null; +} + +export function setStickyComposerRuntimeMode( + projectKey: string, + runtimeMode: RuntimeMode | null | undefined, +): void { + const key = projectKey.trim(); + if (key.length === 0) { + return; + } + const current = appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom); + const nextMode = runtimeMode ?? null; + if ((current[key] ?? null) === nextMode) { + return; + } + const next = { ...current }; + if (nextMode === null) { + delete next[key]; + } else { + next[key] = nextMode; + } + appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, next); + schedulePersistComposerState(); +} + export function setComposerDraftText(draftKey: string, value: string): void { updateComposerDrafts((current) => { const draft = { @@ -1491,3 +1569,14 @@ export function useStickyComposerModelSelection(): ModelSelection | null { }, []); return selection; } + +export function useStickyComposerRuntimeMode(projectKey: string | null): RuntimeMode | null { + const stickyByProject = useAtomValue(stickyComposerRuntimeModeByProjectKeyAtom); + useEffect(() => { + ensureComposerDraftsLoaded(); + }, []); + if (!projectKey) { + return null; + } + return stickyByProject[projectKey] ?? null; +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 8f10bc92acf8..7955e8df1ae8 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -31,6 +31,7 @@ import { pickComposerMedia, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; @@ -48,6 +49,7 @@ import { removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, + setStickyComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; @@ -62,6 +64,7 @@ import { composerAttachmentUploadBlockReason, composerAttachmentUploadsAtom, } from "./composer-attachment-uploads"; +import { useMobileProjectGroupingSettings } from "./project-grouping"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -105,8 +108,10 @@ export function useThreadComposerState() { const { selectedThread: selectedThreadShell, selectedThreadCreation, + selectedThreadProject, selectedEnvironmentRuntime, } = useThreadSelection(); + const projectGroupingSettings = useMobileProjectGroupingSettings(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const acknowledgedMessages = useAtomValue(acknowledgedThreadMessagesAtom); @@ -590,8 +595,15 @@ export function useThreadComposerState() { return; } updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + if (!selectedThreadProject) { + return; + } + setStickyComposerRuntimeMode( + deriveLogicalProjectKeyFromSettings(selectedThreadProject, projectGroupingSettings), + value, + ); }, - [selectedThreadKey], + [projectGroupingSettings, selectedThreadKey, selectedThreadProject], ); const onUpdateInteractionMode = useCallback( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 821044f2e774..3f5439c47cc9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -53,6 +53,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftPromotionNavigationTarget, + resolveNewPullRequestDraftRuntimeMode, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, @@ -259,6 +260,24 @@ describe("proactive panels", () => { expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); }); + it("carries the viewed thread mode into a new pull-request draft", () => { + expect( + resolveNewPullRequestDraftRuntimeMode({ + viewedThreadRuntimeMode: "approval-required", + stickyRuntimeMode: null, + configuredRuntimeMode: "full-access", + }), + ).toBe("approval-required"); + expect( + resolveNewPullRequestDraftRuntimeMode({ + composerRuntimeMode: "auto-accept-edits", + viewedThreadRuntimeMode: "approval-required", + stickyRuntimeMode: "full-access", + configuredRuntimeMode: "full-access", + }), + ).toBe("auto-accept-edits"); + }); + it("follows a changed server PR link without replacing an unrelated open panel", () => { const previous = { projectId: ProjectId.make("project-1"), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 950a9c73fa91..ca37ae36a0d9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -11,6 +11,7 @@ import { type ProviderInteractionMode, ProviderDriverKind, type ProviderInstanceId, + type RuntimeMode, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, @@ -24,6 +25,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { videoMimeType } from "@t3tools/shared/video"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { appendCodexArtifactTemplateUsePrompt, codexArtifactTemplateUsePrompt, @@ -629,6 +631,24 @@ export interface PullRequestDialogState { key: number; } +/** + * Access mode for a freshly minted pull-request draft opened from ChatView. + * Composer overrides win over the viewed thread/session, then sticky and + * the machine default — same carry precedence as new-thread creation. + */ +export function resolveNewPullRequestDraftRuntimeMode(sources: { + readonly composerRuntimeMode?: RuntimeMode | null; + readonly viewedThreadRuntimeMode?: RuntimeMode | null; + readonly stickyRuntimeMode?: RuntimeMode | null; + readonly configuredRuntimeMode?: RuntimeMode | null; +}): RuntimeMode { + return resolveNewThreadRuntimeMode({ + carryRuntimeMode: sources.composerRuntimeMode ?? sources.viewedThreadRuntimeMode ?? null, + stickyRuntimeMode: sources.stickyRuntimeMode ?? null, + configuredRuntimeMode: sources.configuredRuntimeMode ?? null, + }); +} + export function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..9d37f2d57ecc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -409,6 +409,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + resolveNewPullRequestDraftRuntimeMode, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, @@ -1500,6 +1501,8 @@ export default function ChatView(props: ChatViewProps) { const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); + const setStickyRuntimeMode = useComposerDraftStore((store) => store.setStickyRuntimeMode); + const getStickyRuntimeMode = useComposerDraftStore((store) => store.getStickyRuntimeMode); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const citationLocation = useLocation({ @@ -2023,10 +2026,10 @@ export default function ChatView(props: ChatViewProps) { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const activeDraftLogicalProjectKey = - !isServerThread && activeProject - ? deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings) - : undefined; + const activeLogicalProjectKey = activeProject + ? deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings) + : undefined; + const activeDraftLogicalProjectKey = !isServerThread ? activeLogicalProjectKey : undefined; const handleOpenDraftProjectSettings = useCallback(() => { if (!activeDraftLogicalProjectKey) return; void navigate({ @@ -2265,10 +2268,16 @@ export default function ChatView(props: ChatViewProps) { const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); + const resolvedRuntimeMode = resolveNewPullRequestDraftRuntimeMode({ + composerRuntimeMode, + viewedThreadRuntimeMode: activeThread?.runtimeMode ?? null, + stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), + configuredRuntimeMode: settings.defaultRuntimeMode ?? null, + }); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: resolvedRuntimeMode, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); @@ -2280,15 +2289,19 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, + activeThread?.runtimeMode, + composerRuntimeMode, draftId, getDraftSession, getDraftSessionByLogicalProjectKey, + getStickyRuntimeMode, isServerThread, navigate, projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, + settings.defaultRuntimeMode, ], ); @@ -4046,15 +4059,20 @@ export default function ChatView(props: ChatViewProps) { if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } + if (activeLogicalProjectKey) { + setStickyRuntimeMode(activeLogicalProjectKey, mode); + } scheduleComposerFocus(); }, [ + activeLogicalProjectKey, isLocalDraftThread, runtimeMode, scheduleComposerFocus, composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, + setStickyRuntimeMode, ], ); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 938000e01002..85753c2da306 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -4,6 +4,7 @@ import { type EnvironmentId, type ModelSelection, type ProviderInstanceId, + type RuntimeMode, type ServerSettingsPatch, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; @@ -41,6 +42,13 @@ import { SettingsSection, } from "./settingsLayout"; +const DEFAULT_RUNTIME_MODE_LABELS: Record = { + "approval-required": "Supervised", + "auto-accept-edits": "Auto-accept edits", + auto: "Auto", + "full-access": "Full access", +}; + /** Defaults are written only to the machines selected on the projects settings page. */ export function ProjectDefaultsSettings({ environmentId, @@ -91,6 +99,10 @@ export function ProjectDefaultsSettings({ (target) => target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, ); + const mixedRuntimeMode = targets.some( + (target) => + target.serverConfig?.settings.defaultRuntimeMode !== serverSettings.defaultRuntimeMode, + ); const mixedBrowser = targets.some( (target) => target.serverConfig?.settings.enableAgentBrowserAccess !== @@ -313,6 +325,64 @@ export function ProjectDefaultsSettings({ } /> + + void save({ defaultRuntimeMode: DEFAULT_SERVER_SETTINGS.defaultRuntimeMode }) + } + /> + ) : null + } + control={ + + } + /> void) { ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.defaultRuntimeMode !== DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode + ? ["Default access"] + : []), ...(settings.newWorktreesStartFromOrigin !== DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] @@ -605,6 +608,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.composerCollapseOnScroll, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.defaultRuntimeMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.diffLayout, @@ -724,6 +728,7 @@ export function useSettingsRestore(onRestored?: () => void) { automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + defaultRuntimeMode: DEFAULT_UNIFIED_SETTINGS.defaultRuntimeMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e32baf6f3987..fc7ab133bfcb 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -72,7 +72,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Project defaults and overrides", to: "/settings/projects", searchTerms: [ - "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts", + "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts access permission runtime supervised", ], }, { @@ -248,6 +248,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/projects", searchTerms: ["default workspace mode draft local worktree"], }, + { + id: "default-access", + title: "Default access", + to: "/settings/projects", + searchTerms: [ + "permission runtime mode supervised auto-accept full access yolo new thread default", + ], + }, { id: "start-from-origin", title: "Start from origin", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 919342adbfc2..37e7eb9abcf4 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -150,6 +150,7 @@ function resetComposerDraftStore() { logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + stickyRuntimeModeByLogicalProjectKey: {}, }); } @@ -731,6 +732,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + stickyRuntimeModeByLogicalProjectKey: {}, }); }); @@ -786,6 +788,7 @@ describe("composerDraftStore terminal contexts", () => { logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + stickyRuntimeModeByLogicalProjectKey: {}, }); }); @@ -2546,6 +2549,66 @@ describe("composerDraftStore provider-scoped option updates", () => { }); }); +describe("composerDraftStore sticky runtime mode", () => { + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("stores a sticky runtime mode per logical project", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyRuntimeMode("project-a", "approval-required"); + store.setStickyRuntimeMode("project-b", "auto-accept-edits"); + + expect(store.getStickyRuntimeMode("project-a")).toBe("approval-required"); + expect(store.getStickyRuntimeMode("project-b")).toBe("auto-accept-edits"); + expect(useComposerDraftStore.getState().stickyRuntimeModeByLogicalProjectKey).toEqual({ + "project-a": "approval-required", + "project-b": "auto-accept-edits", + }); + }); + + it("clears sticky runtime mode for a project", () => { + const store = useComposerDraftStore.getState(); + + store.setStickyRuntimeMode("project-a", "approval-required"); + store.setStickyRuntimeMode("project-a", null); + + expect(store.getStickyRuntimeMode("project-a")).toBeNull(); + expect(useComposerDraftStore.getState().stickyRuntimeModeByLogicalProjectKey).toEqual({}); + }); + + it("reads an intermediate lastUsedRuntimeModeByLogicalProjectKey on hydrate", async () => { + vi.useFakeTimers(); + try { + await useComposerDraftStore.persist.clearStorage(); + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 9, + state: { + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + lastUsedRuntimeModeByLogicalProjectKey: { + "project-legacy": "auto", + }, + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + await useComposerDraftStore.persist.rehydrate(); + + expect(useComposerDraftStore.getState().stickyRuntimeModeByLogicalProjectKey).toEqual({ + "project-legacy": "auto", + }); + expect(useComposerDraftStore.getState().getStickyRuntimeMode("project-legacy")).toBe("auto"); + } finally { + vi.useRealTimers(); + await useComposerDraftStore.persist.clearStorage(); + } + }); +}); + describe("composerDraftStore runtime and interaction settings", () => { const threadId = ThreadId.make("thread-settings"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8f4ad48e16ab..36d46c1a1bc7 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -307,6 +307,7 @@ type LegacyV2StoreFields = { draftThreadsByThreadKey?: Record | null; projectDraftThreadKeyByProjectKey?: Record | null; logicalProjectDraftThreadKeyByLogicalProjectKey?: Record | null; + lastUsedRuntimeModeByLogicalProjectKey?: Record | null; }; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & @@ -346,6 +347,10 @@ const PersistedComposerDraftStoreState = Schema.Struct({ Schema.Record(ProviderInstanceId, ModelSelection), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), + /** Sticky runtime/access mode keyed by logical project identity. */ + stickyRuntimeModeByLogicalProjectKey: Schema.optionalKey( + Schema.Record(Schema.String, RuntimeMode), + ), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -477,6 +482,7 @@ interface ComposerDraftStoreState { backgroundSubmissionThreadKeys: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderInstanceId | null; + stickyRuntimeModeByLogicalProjectKey: Partial>; /** Returns the editable composer content for a draft session or server thread. */ getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; /** Looks up the active draft session for a logical project identity. */ @@ -558,6 +564,11 @@ interface ComposerDraftStoreState { finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; + getStickyRuntimeMode: (logicalProjectKey: string) => RuntimeMode | null; + setStickyRuntimeMode: ( + logicalProjectKey: string, + runtimeMode: RuntimeMode | null | undefined, + ) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; setModelSelection: ( @@ -730,12 +741,30 @@ function compactModelSelectionByProvider( return Object.fromEntries(entries) as DeepMutable>; } +function normalizeStickyRuntimeModeByLogicalProjectKey( + value: unknown, +): Record { + if (!value || typeof value !== "object") { + return {}; + } + const next: Record = {}; + for (const [logicalProjectKey, runtimeMode] of Object.entries(value)) { + const key = logicalProjectKey.trim(); + if (key.length === 0 || !isRuntimeMode(runtimeMode)) { + continue; + } + next[key] = runtimeMode; + } + return next; +} + const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + stickyRuntimeModeByLogicalProjectKey: {}, }); const EMPTY_IMAGES: ComposerImageAttachment[] = []; @@ -2227,6 +2256,9 @@ export function partializeComposerDraftStoreState( state.stickyModelSelectionByProvider, ), stickyActiveProvider: state.stickyActiveProvider, + stickyRuntimeModeByLogicalProjectKey: normalizeStickyRuntimeModeByLogicalProjectKey( + state.stickyRuntimeModeByLogicalProjectKey, + ), }; } @@ -2297,6 +2329,10 @@ function normalizeCurrentPersistedComposerDraftStoreState( logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), stickyActiveProvider, + stickyRuntimeModeByLogicalProjectKey: normalizeStickyRuntimeModeByLogicalProjectKey( + normalizedPersistedState.stickyRuntimeModeByLogicalProjectKey ?? + normalizedPersistedState.lastUsedRuntimeModeByLogicalProjectKey, + ), }; } @@ -2524,6 +2560,7 @@ const composerDraftStore = create()( backgroundSubmissionThreadKeys: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + stickyRuntimeModeByLogicalProjectKey: {}, getComposerDraft: (target) => getComposerDraftState(get(), target), getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); @@ -2942,6 +2979,33 @@ const composerDraftStore = create()( }; }); }, + getStickyRuntimeMode: (logicalProjectKey) => { + const key = logicalProjectDraftKey(logicalProjectKey); + if (key.length === 0) { + return null; + } + return get().stickyRuntimeModeByLogicalProjectKey[key] ?? null; + }, + setStickyRuntimeMode: (logicalProjectKey, runtimeMode) => { + const key = logicalProjectDraftKey(logicalProjectKey); + if (key.length === 0) { + return; + } + const nextRuntimeMode = isRuntimeMode(runtimeMode) ? runtimeMode : null; + set((state) => { + const current = state.stickyRuntimeModeByLogicalProjectKey[key] ?? null; + if (current === nextRuntimeMode) { + return state; + } + const nextMap = { ...state.stickyRuntimeModeByLogicalProjectKey }; + if (nextRuntimeMode === null) { + delete nextMap[key]; + } else { + nextMap[key] = nextRuntimeMode; + } + return { stickyRuntimeModeByLogicalProjectKey: nextMap }; + }); + }, applyStickyState: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -4008,6 +4072,8 @@ const composerDraftStore = create()( normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, + stickyRuntimeModeByLogicalProjectKey: + normalizedPersisted.stickyRuntimeModeByLogicalProjectKey ?? {}, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index afd503e63c25..24db4ea10522 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -23,10 +23,14 @@ const testState = vi.hoisted(() => { getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), getDraftSession: vi.fn(() => null), getDraftThread: vi.fn(() => null), + getStickyRuntimeMode: vi.fn((): string | null => null), + setStickyRuntimeMode: vi.fn(), applyStickyState: vi.fn(), setDraftThreadContext: vi.fn(), setLogicalProjectDraftThreadId: vi.fn(), setModelSelection: vi.fn(), + setRuntimeMode: vi.fn(), + setInteractionMode: vi.fn(), }; return { @@ -39,7 +43,15 @@ const testState = vi.hoisted(() => { storedDraft = nextStoredDraft; router.state.location.href = "/"; router.navigate.mockClear(); + draftStore.getComposerDraft.mockReset(); + draftStore.getComposerDraft.mockImplementation(() => ({})); + draftStore.getStickyRuntimeMode.mockReset(); + draftStore.getStickyRuntimeMode.mockImplementation(() => null); draftStore.setLogicalProjectDraftThreadId.mockClear(); + draftStore.setRuntimeMode.mockClear(); + draftStore.setInteractionMode.mockClear(); + draftStore.setDraftThreadContext.mockClear(); + draftStore.setStickyRuntimeMode.mockClear(); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); @@ -71,7 +83,6 @@ vi.mock("@t3tools/client-runtime/environment", () => ({ scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); vi.mock("@t3tools/contracts", () => ({ - DEFAULT_RUNTIME_MODE: "default", DEFAULT_SERVER_SETTINGS: {}, })); vi.mock("@t3tools/shared/threadEnvMode", () => ({ @@ -80,6 +91,19 @@ vi.mock("@t3tools/shared/threadEnvMode", () => ({ readonly globalDefault: "local" | "worktree"; }) => input.projectFile ?? input.globalDefault, })); +vi.mock("@t3tools/shared/runtimeMode", () => ({ + resolveNewThreadRuntimeMode: (sources: { + readonly draftRuntimeMode?: string | null; + readonly carryRuntimeMode?: string | null; + readonly stickyRuntimeMode?: string | null; + readonly configuredRuntimeMode?: string | null; + }) => + sources.draftRuntimeMode ?? + sources.carryRuntimeMode ?? + sources.stickyRuntimeMode ?? + sources.configuredRuntimeMode ?? + "full-access", +})); vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, @@ -171,4 +195,73 @@ describe("useNewThreadHandler", () => { expect(testState.router.navigate).not.toHaveBeenCalled(); expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); + + it("writes resolved runtime mode onto the draft session when resurrecting an empty draft", 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.setDraftThreadContext).toHaveBeenCalledWith( + "draft-existing", + expect.objectContaining({ runtimeMode: "full-access" }), + ); + // No composer pick yet — do not seed sticky into the composer draft. + expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); + expect(testState.router.navigate).toHaveBeenCalled(); + }); + + it("keeps an empty draft's explicit composer runtime mode on resurrect", async () => { + testState.reset({ + draftId: "draft-existing", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-existing", + }); + testState.draftStore.getComposerDraft.mockImplementation(() => ({ + runtimeMode: "approval-required", + })); + testState.draftStore.getStickyRuntimeMode.mockImplementation(() => "auto-accept-edits"); + const openThread = useNewThreadHandler(); + const pendingOpen = openThread({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith( + "draft-existing", + expect.objectContaining({ runtimeMode: "approval-required" }), + ); + expect(testState.draftStore.setRuntimeMode).toHaveBeenCalledWith( + "draft-existing", + "approval-required", + ); + }); + + it("does not seed sticky from the machine default alone", async () => { + testState.reset(null); + const openThread = useNewThreadHandler(); + const pendingOpen = openThread({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.draftStore.setStickyRuntimeMode).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 9b2afb3a61f6..4d53eba2ad39 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,12 +4,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { - DEFAULT_RUNTIME_MODE, - DEFAULT_SERVER_SETTINGS, - type ScopedProjectRef, - type ThreadId, -} from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -28,6 +23,7 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; import { hasExplicitComposerModelSelection, @@ -91,18 +87,25 @@ export function useNewThreadHandler() { getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, + getStickyRuntimeMode, + setStickyRuntimeMode, applyStickyState, setDraftThreadContext, setLogicalProjectDraftThreadId, setModelSelection, + setRuntimeMode, + setInteractionMode, } = useComposerDraftStore.getState(); 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; runtime and - // interaction modes carry independently. Branch, worktree, and env mode - // come from configured defaults unless the caller passes them explicitly. + // viewed only when that source belongs to the same logical project. + // Otherwise the project's sticky mode wins over the hardcoded + // full-access default. The target project's configured model still wins; + // interaction/plan mode carries independently and is not sticky. + // Branch, worktree, and env mode come from configured defaults unless + // the caller passes them explicitly. const carrySourceShell = currentRouteTarget?.kind === "server" ? readThreadShell(currentRouteTarget.threadRef) @@ -167,6 +170,26 @@ export function useNewThreadHandler() { const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) : scopedProjectKey(projectRef); + const carrySourceLogicalProjectKey = + currentRouteTarget?.kind === "server" && carrySourceShell + ? (() => { + const carryProject = projects.find( + (candidate) => + candidate.id === carrySourceShell.projectId && + candidate.environmentId === currentRouteTarget.threadRef.environmentId, + ); + return carryProject + ? deriveLogicalProjectKeyFromSettings(carryProject, projectGroupingSettings) + : scopedProjectKey( + scopeProjectRef( + currentRouteTarget.threadRef.environmentId, + carrySourceShell.projectId, + ), + ); + })() + : (carrySourceDraft?.logicalProjectKey ?? null); + const sameProjectCarryRuntimeMode = + carrySourceLogicalProjectKey === logicalProjectKey ? carryRuntimeMode : null; const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; @@ -196,6 +219,26 @@ export function useNewThreadHandler() { !composerDraftHasUserContent(getComposerDraft(reusableStoredDraftThread.draftId)) ? reusableStoredDraftThread : null; + // Composer runtimeMode is the explicit-pick signal (nullable). The draft + // session always carries a seeded mode from mint, so using that here + // would permanently shadow later sticky / Default access changes on + // empty-draft resurrection. Prefer the composer's own mode when present. + const emptyDraftComposerRuntimeMode = + emptyStoredDraftThread != null + ? (getComposerDraft(emptyStoredDraftThread.draftId)?.runtimeMode ?? null) + : null; + const resolvedRuntimeMode = resolveNewThreadRuntimeMode({ + draftRuntimeMode: emptyDraftComposerRuntimeMode, + carryRuntimeMode: sameProjectCarryRuntimeMode, + stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), + configuredRuntimeMode: targetServerSettings.defaultRuntimeMode, + }); + // Only promote carry into sticky. Seeding from the machine default would + // permanently shadow later Default access changes (including Auto). + // Explicit composer picks update sticky in ChatView. + if (sameProjectCarryRuntimeMode != null) { + setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); + } const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) @@ -265,10 +308,20 @@ export function useNewThreadHandler() { if (workspaceContext) { setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, - ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + runtimeMode: resolvedRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); } + // Composer mode is what the picker reads. Only write when the + // composer already has an explicit pick — seeding it on every + // resurrect would turn ambient sticky / machine defaults into + // sticky draft picks that shadow later Default access changes. + if (emptyDraftComposerRuntimeMode != null) { + setRuntimeMode(emptyStoredDraftThread.draftId, resolvedRuntimeMode); + } + if (carryInteractionMode) { + setInteractionMode(emptyStoredDraftThread.draftId, carryInteractionMode); + } // Model intent: an explicit human pick always stands. Seeds and // legacy entries alike re-resolve here — sticky first, mirroring // the mint-fresh path, then the project default or carried @@ -302,7 +355,7 @@ export function useNewThreadHandler() { { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, - ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + runtimeMode: resolvedRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); @@ -415,7 +468,7 @@ export function useNewThreadHandler() { envMode: initialEnvMode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), - runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode: resolvedRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7d3cd2ceafe7..64679bb14a19 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { DEFAULT_RUNTIME_MODE } from "./orchestration.ts"; import { ClientSettingsSchema, ClientSettingsPatch, @@ -20,6 +21,18 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); +describe("ServerSettings default runtime mode", () => { + it("defaults new thread access mode to the existing runtime default", () => { + expect(DEFAULT_SERVER_SETTINGS.defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); + expect(decodeServerSettings({}).defaultRuntimeMode).toBe(DEFAULT_RUNTIME_MODE); + }); + + it("accepts runtime mode patches", () => { + const patch = decodeServerSettingsPatch({ defaultRuntimeMode: "approval-required" }); + expect(patch.defaultRuntimeMode).toBe("approval-required"); + }); +}); + describe("ServerSettings usage price overrides", () => { const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..30e0c2479524 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -17,7 +17,12 @@ import { DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, } from "./model.ts"; -import { ModelSelection, ProjectScript } from "./orchestration.ts"; +import { + DEFAULT_RUNTIME_MODE, + ModelSelection, + ProjectScript, + RuntimeMode, +} from "./orchestration.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, @@ -1006,6 +1011,13 @@ export const ServerSettings = Schema.Struct({ defaultThreadEnvMode: ThreadEnvMode.pipe( Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), ), + /** + * Preferred access mode for new threads when nothing carries from the + * current view and the client has no sticky mode for the project. + */ + defaultRuntimeMode: RuntimeMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), + ), newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), @@ -1249,6 +1261,7 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + defaultRuntimeMode: Schema.optionalKey(RuntimeMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), diff --git a/packages/shared/package.json b/packages/shared/package.json index 5573e0ab6b69..4546e3b78fdc 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -107,6 +107,10 @@ "types": "./src/threadEnvMode.ts", "import": "./src/threadEnvMode.ts" }, + "./runtimeMode": { + "types": "./src/runtimeMode.ts", + "import": "./src/runtimeMode.ts" + }, "./t3ProjectFile": { "types": "./src/t3ProjectFile.ts", "import": "./src/t3ProjectFile.ts" diff --git a/packages/shared/src/runtimeMode.test.ts b/packages/shared/src/runtimeMode.test.ts new file mode 100644 index 000000000000..e317b22deece --- /dev/null +++ b/packages/shared/src/runtimeMode.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveNewThreadRuntimeMode } from "./runtimeMode.ts"; + +describe("resolveNewThreadRuntimeMode", () => { + it("prefers an explicit draft pick over carry, sticky, and configured", () => { + expect( + resolveNewThreadRuntimeMode({ + draftRuntimeMode: "approval-required", + carryRuntimeMode: "auto-accept-edits", + stickyRuntimeMode: "full-access", + configuredRuntimeMode: "auto", + }), + ).toBe("approval-required"); + }); + + it("prefers same-project carry over sticky and configured", () => { + expect( + resolveNewThreadRuntimeMode({ + carryRuntimeMode: "auto-accept-edits", + stickyRuntimeMode: "approval-required", + configuredRuntimeMode: "auto", + }), + ).toBe("auto-accept-edits"); + }); + + it("uses project sticky when nothing carries", () => { + expect( + resolveNewThreadRuntimeMode({ + stickyRuntimeMode: "approval-required", + configuredRuntimeMode: "auto", + }), + ).toBe("approval-required"); + }); + + it("uses the configured default when sticky is absent", () => { + expect( + resolveNewThreadRuntimeMode({ + configuredRuntimeMode: "auto", + }), + ).toBe("auto"); + }); + + it("falls back to full-access when no preference exists", () => { + expect(resolveNewThreadRuntimeMode({})).toBe("full-access"); + }); +}); diff --git a/packages/shared/src/runtimeMode.ts b/packages/shared/src/runtimeMode.ts new file mode 100644 index 000000000000..e4ecdaaa8c71 --- /dev/null +++ b/packages/shared/src/runtimeMode.ts @@ -0,0 +1,24 @@ +import { DEFAULT_RUNTIME_MODE, type RuntimeMode } from "@t3tools/contracts"; + +/** + * Canonical priority for a new thread's runtime/access mode once the caller + * has decided whether a carry source is eligible (same project only): + * explicit draft pick > same-project carry > project sticky > configured + * machine/project default > hardcoded default. + * + * Interaction/plan mode stays out of this resolver on purpose. + */ +export function resolveNewThreadRuntimeMode(sources: { + readonly draftRuntimeMode?: RuntimeMode | null; + readonly carryRuntimeMode?: RuntimeMode | null; + readonly stickyRuntimeMode?: RuntimeMode | null; + readonly configuredRuntimeMode?: RuntimeMode | null; +}): RuntimeMode { + return ( + sources.draftRuntimeMode ?? + sources.carryRuntimeMode ?? + sources.stickyRuntimeMode ?? + sources.configuredRuntimeMode ?? + DEFAULT_RUNTIME_MODE + ); +}