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 d0687e7e7b66..c89acdf62712 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, DEFAULT_SERVER_SETTINGS, MessageId, T3_PROJECT_FILE_NAME, @@ -38,6 +37,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"; @@ -56,10 +56,13 @@ import { scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, setStickyComposerModelSelection, + setStickyComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, useStickyComposerModelSelection, + useStickyComposerRuntimeMode, } from "../../state/use-composer-drafts"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { capturePendingTaskEditorWriteBaseline, flushPendingTaskEditorWrite, @@ -467,7 +470,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const draftStartFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin; const startFromOrigin = draftStartFromOrigin ?? projectSettings.settings.newWorktreesStartFromOrigin; - 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 @@ -485,6 +487,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: projectSettings.settings.defaultRuntimeMode, + }); const modelOptions = useMemo( () => buildModelOptions( @@ -888,8 +901,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) => { @@ -978,7 +994,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, @@ -1020,8 +1036,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 ae0cdc8581ff..9282821081b2 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, @@ -306,6 +307,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 b6bb98b4c50a..baa9d7f903b6 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, @@ -25,6 +26,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, @@ -778,6 +780,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 212c23ecdb3d..ae0479e6b47e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -417,6 +417,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + resolveNewPullRequestDraftRuntimeMode, isPaintOnlyThreadTimeline, peekHeldThreadTimeline, peekRememberedThreadTimeline, @@ -1523,6 +1524,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({ @@ -2047,10 +2050,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({ @@ -2289,10 +2292,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: activeProjectSettings.settings.defaultRuntimeMode ?? null, + }); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: resolvedRuntimeMode, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); @@ -2304,9 +2313,13 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, + activeThread?.runtimeMode, + activeProjectSettings.settings.defaultRuntimeMode, + composerRuntimeMode, draftId, getDraftSession, getDraftSessionByLogicalProjectKey, + getStickyRuntimeMode, isServerThread, navigate, projectGroupingSettings, @@ -4120,15 +4133,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 bb2c3e99bbb8..80d6364b7ed6 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -3,6 +3,7 @@ import { EnvironmentId, type ModelSelection, type ProviderInstanceId, + type RuntimeMode, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { useNavigate } from "@tanstack/react-router"; @@ -40,6 +41,13 @@ import { useUpdateScopedSettings, } from "./useScopedSettings"; +const DEFAULT_RUNTIME_MODE_LABELS: Record = { + "approval-required": "Supervised", + "auto-accept-edits": "Auto-accept edits", + auto: "Auto", + "full-access": "Full access", +}; + /** * Rows for the settings a project may override. The same rows edit * environment defaults at an environment scope and project overrides at a @@ -68,6 +76,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); + const mixedRuntimeMode = useScopedSettingsMixed(["defaultRuntimeMode"]); const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); @@ -220,6 +229,72 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting ) } /> + + updateSettings({ + defaultRuntimeMode: DEFAULT_SERVER_SETTINGS.defaultRuntimeMode, + }) + } + /> + ) : null + } + control={ + + } + /> { ["Off", false], ]); }); + + it("uses a human-readable label for access mode values", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + defaultRuntimeMode: "auto" as const, + projectSettingsOverrides: { + [projectId]: { defaultRuntimeMode: "approval-required" as const }, + }, + }; + const resolved = resolveProjectSettings(settings, projectId); + const layers = settingInheritanceLayers( + { environmentId, label: "Laptop", projectId, ...resolved }, + settings, + "defaultRuntimeMode", + ); + expect(layers.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([ + ["Project", "Supervised", true], + ["Laptop", "Auto", false], + ["Default", "Full access", false], + ]); + }); }); diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 4bec97f9132e..43767583e72c 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -31,6 +31,13 @@ const WRITING_STYLE_LABELS: Record = { custom: "Custom instructions", }; +const RUNTIME_MODE_LABELS: Record = { + "approval-required": "Supervised", + "auto-accept-edits": "Auto-accept edits", + auto: "Auto", + "full-access": "Full access", +}; + /** Human labels for the values the chain can show; falls back to a type summary. */ function formatValue(key: keyof ServerSettings, value: unknown): string { if (value === null || value === undefined) { @@ -54,6 +61,10 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) { return resolveEnvModeLabel(value); } + if (key === "defaultRuntimeMode") { + const runtimeModeLabel = RUNTIME_MODE_LABELS[value]; + if (runtimeModeLabel !== undefined) return runtimeModeLabel; + } if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) { return PULL_REQUEST_MERGE_METHOD_LABELS[ value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 17af7cc9a115..3fdf2175e851 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -565,6 +565,9 @@ export function useSettingsRestore(onRestored?: () => 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"] @@ -607,6 +610,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.composerCollapseOnScroll, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.defaultRuntimeMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.diffLayout, @@ -727,6 +731,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.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 6b4a5f9cdd82..3b5b0e60b7b5 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -264,6 +264,7 @@ describe("searchSettings", () => { it.each([ ["default model", "default-model", "/settings/general"], + ["default access", "default-access", "/settings/general"], ["new threads", "new-threads", "/settings/general"], ["agent browser access", "agent-browser-access", "/settings/integrations"], ["automatically pull", "automatic-pull", "/settings/source-control"], diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index aa5b95642b9b..bd06585a3882 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -108,6 +108,15 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["new thread project provider reasoning effort"], }, + { + id: "default-access", + title: "Default access", + to: "/settings/general", + scope: "project-defaults", + searchTerms: [ + "permission runtime mode supervised auto-accept full access yolo new thread default", + ], + }, { id: "color-scheme", title: "Color scheme", 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 84d59f05a83d..d4a0fa1e9be7 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -7,6 +7,7 @@ const testState = vi.hoisted(() => { defaultThreadEnvMode: "local" as "local" | "worktree", newWorktreesStartFromOrigin: false, defaultModelSelection: null, + defaultRuntimeMode: "full-access" as const, }; let storedDraft: { readonly draftId: string; @@ -23,15 +24,27 @@ const testState = vi.hoisted(() => { router.state.location.href = `/draft/${request.params.draftId}`; }), }; + let routeTarget: { readonly kind: "draft"; readonly draftId: string } | null = null; + let viewedDraftSession: { + readonly draftId: string; + readonly logicalProjectKey: string; + readonly runtimeMode: string; + } | null = null; const draftStore = { getComposerDraft: vi.fn(() => ({})), getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), - getDraftSession: vi.fn(() => null), + getDraftSession: vi.fn((draftId: string) => + viewedDraftSession?.draftId === draftId ? viewedDraftSession : 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 { @@ -55,16 +68,39 @@ const testState = vi.hoisted(() => { defaultThreadEnvMode: workspaceDefaults.envMode, newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin, defaultModelSelection: null, + defaultRuntimeMode: "full-access", }; + routeTarget = null; + viewedDraftSession = null; router.state.location.href = "/"; router.navigate.mockClear(); - draftStore.setDraftThreadContext.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; }); }, + routeTarget: { + get current() { + return routeTarget; + }, + set(next: typeof routeTarget) { + routeTarget = next; + }, + }, router, + viewedDraftSession: { + set(next: typeof viewedDraftSession) { + viewedDraftSession = next; + }, + }, }; }); @@ -91,7 +127,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/projectSettings", () => ({ @@ -109,6 +144,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, @@ -163,7 +211,9 @@ vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {}, primaryServerSettingsAtom: "primary-settings", })); -vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../threadRoutes", () => ({ + resolveThreadRouteTarget: () => testState.routeTarget.current, +})); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", useUiStateStore: () => [], @@ -201,6 +251,122 @@ describe.each([ 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(); + }); + + it("does not sticky a viewed draft's seeded runtime mode", async () => { + testState.reset(null); + testState.routeTarget.set({ kind: "draft", draftId: "draft-viewed" }); + testState.viewedDraftSession.set({ + draftId: "draft-viewed", + logicalProjectKey: "remote-project", + runtimeMode: "full-access", + }); + // Composer has no explicit pick — only the draft-session seed exists. + testState.draftStore.getComposerDraft.mockImplementation(() => ({})); + 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(); + }); + + it("stickies carry from an explicit composer pick on a viewed draft", async () => { + testState.reset(null); + testState.routeTarget.set({ kind: "draft", draftId: "draft-viewed" }); + testState.viewedDraftSession.set({ + draftId: "draft-viewed", + logicalProjectKey: "remote-project", + runtimeMode: "full-access", + }); + testState.draftStore.getComposerDraft.mockImplementation(() => ({ + runtimeMode: "approval-required", + })); + const openThread = useNewThreadHandler(); + const pendingOpen = openThread({ + environmentId: "environment-ssh", + projectId: "project-remote", + } as never); + + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.draftStore.setStickyRuntimeMode).toHaveBeenCalledWith( + "remote-project", + "approval-required", + ); + }); 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 c72bef079967..545b3373ffb6 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 { @@ -29,6 +24,7 @@ import { } from "../logicalProject"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; 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) @@ -178,6 +181,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; @@ -207,6 +230,32 @@ 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: projectSettings.settings.defaultRuntimeMode, + }); + // Carry still fills the immediate new draft, but only promote into sticky + // when the carry came from an explicit composer pick or a real server + // thread. A draft session always has a seeded runtimeMode from mint; using + // that alone would sticky Full access (or whatever was configured at mint) + // and permanently shadow later Default access changes. + // Explicit composer picks also update sticky in ChatView. + const shouldPersistCarryAsSticky = + sameProjectCarryRuntimeMode != null && + (carrySourceComposer?.runtimeMode != null || carrySourceShell != null); + if (shouldPersistCarryAsSticky) { + setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); + } const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) @@ -276,10 +325,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 @@ -313,7 +372,7 @@ export function useNewThreadHandler() { { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, - ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + runtimeMode: resolvedRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); @@ -426,7 +485,7 @@ export function useNewThreadHandler() { envMode: initialEnvMode, newWorktreesStartFromOrigin: projectSettings.settings.newWorktreesStartFromOrigin, }), - runtimeMode: carryRuntimeMode ?? DEFAULT_RUNTIME_MODE, + runtimeMode: resolvedRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); applyStickyState(draftId); diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index e4fae5a86c3b..b5de75af4c88 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -28,9 +28,9 @@ one until you pick another. Every other setting fans out to the selection. ## Defaults and inheritance -General contains the model and workspace for new threads. Integrations controls agent browser -access. Source Control contains automatic pull, the default pull request merge method and text -generation. The same rows edit environment defaults or project overrides depending on the +General contains the model, access mode and workspace for new threads. Integrations controls agent +browser access. Source Control contains automatic pull, the default pull request merge method and +text generation. The same rows edit environment defaults or project overrides depending on the project crumb. The Project category, shown while a project is selected, holds the project's name, icon, actions, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7322307c1f89..7377d41872f1 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 90b176c3095a..afc85f608ab8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -18,7 +18,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, @@ -937,6 +942,7 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultModelSelection", "defaultThreadEnvMode", + "defaultRuntimeMode", "newWorktreesStartFromOrigin", "defaultAutoPull", "defaultProjectScripts", @@ -961,6 +967,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN export const ProjectSettingsOverrides = Schema.Struct({ defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + defaultRuntimeMode: Schema.optionalKey(RuntimeMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), @@ -1093,6 +1100,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)), ), @@ -1359,6 +1373,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 bda80d937b92..6072688ab9f3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -111,6 +111,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/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index 950867d36dc7..d88adabfba03 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -34,14 +34,20 @@ describe("resolveProjectSettings", () => { defaultAutoPull: true, sidebarAutoSettleAfterDays: 3, projectSettingsOverrides: { - [projectId]: { defaultAutoPull: false, sidebarAutoSettleAfterDays: null }, + [projectId]: { + defaultAutoPull: false, + defaultRuntimeMode: "approval-required", + sidebarAutoSettleAfterDays: null, + }, }, }); const resolved = resolveProjectSettings(settings, projectId); expect(resolved.settings.defaultAutoPull).toBe(false); expect(resolved.settings.sidebarAutoSettleAfterDays).toBeNull(); expect(resolved.settings.defaultThreadEnvMode).toBe(settings.defaultThreadEnvMode); + expect(resolved.settings.defaultRuntimeMode).toBe("approval-required"); expect(resolved.sources.defaultAutoPull).toBe("project"); + expect(resolved.sources.defaultRuntimeMode).toBe("project"); expect(resolved.sources.sidebarAutoSettleAfterDays).toBe("project"); expect(resolved.sources.defaultThreadEnvMode).toBe("environment"); expect(resolveProjectSettings(settings, otherProjectId).settings.defaultAutoPull).toBe(true); 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 + ); +}