From 1db9915990287b69ce572cdc3515b28a216ba15c Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Tue, 8 Sep 2026 19:04:44 +0200 Subject: [PATCH 01/11] feat: sticky project access mode with machine default Remember each project's last-used permission mode for new threads, and add a machine-level Default access setting under Project defaults as the fallback. Co-authored-by: Cursor --- .../threads/new-task-flow-provider.tsx | 23 ++++- .../src/state/use-composer-drafts.test.ts | 30 +++++++ apps/mobile/src/state/use-composer-drafts.ts | 88 +++++++++++++++++++ .../src/state/use-thread-composer-state.ts | 11 ++- apps/web/src/components/ChatView.tsx | 26 ++++-- .../settings/ProjectDefaultsSettings.tsx | 70 +++++++++++++++ .../components/settings/SettingsPanels.tsx | 5 ++ .../src/components/settings/settingsSearch.ts | 10 ++- apps/web/src/composerDraftStore.test.ts | 33 +++++++ apps/web/src/composerDraftStore.ts | 64 ++++++++++++++ apps/web/src/hooks/useHandleNewThread.test.ts | 35 +++++++- apps/web/src/hooks/useHandleNewThread.ts | 63 ++++++++++--- packages/contracts/src/settings.test.ts | 13 +++ packages/contracts/src/settings.ts | 15 +++- packages/shared/package.json | 4 + packages/shared/src/runtimeMode.test.ts | 47 ++++++++++ packages/shared/src/runtimeMode.ts | 24 +++++ 17 files changed, 534 insertions(+), 27 deletions(-) create mode 100644 packages/shared/src/runtimeMode.test.ts create mode 100644 packages/shared/src/runtimeMode.ts 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..130e8c136ab3 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, @@ -54,10 +53,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 +454,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 +473,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, storedStickyModelSelection, ); + const stickyRuntimeMode = useStickyComposerRuntimeMode(selectedProjectKey); + const runtimeMode = resolveNewThreadRuntimeMode({ + draftRuntimeMode: selectedProjectDraft.runtimeMode, + stickyRuntimeMode, + configuredRuntimeMode: selectedEnvironmentServerConfig?.settings.defaultRuntimeMode, + }); const modelOptions = useMemo( () => buildModelOptions( @@ -875,8 +882,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (selectedProjectDraftKey) { updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); } + if (selectedProjectKey) { + setStickyComposerRuntimeMode(selectedProjectKey, value); + } }, - [selectedProjectDraftKey], + [selectedProjectDraftKey, selectedProjectKey], ); const setInteractionMode = useCallback( (value: ProviderInteractionMode) => { @@ -957,6 +967,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projectCwd = usingPendingSnapshot ? editingPendingTask?.creation?.projectCwd : selectedProject.workspaceRoot; + if (selectedProjectKey) { + setStickyComposerRuntimeMode(selectedProjectKey, runtimeMode); + } return { environmentId: selectedProject.environmentId, threadId: ThreadId.make(metadata.threadId), @@ -965,7 +978,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 +1020,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..dc31418ea447 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,33 @@ 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("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..d4bf96eef58a 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -127,6 +127,7 @@ 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)), cloudAccountId: Schema.optional(Schema.String), signedOutDrafts: Schema.optional( Schema.Record( @@ -158,6 +159,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 +278,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 +313,9 @@ export function decodePersistedComposerState(value: unknown): { .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, + stickyRuntimeModeByProjectKey: normalizeStickyRuntimeModeByProjectKey( + parsed.stickyRuntimeModeByProjectKey, + ), cloudDrafts: { accountId: parsed.cloudAccountId ?? null, signedOut: Object.fromEntries( @@ -328,6 +337,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 +371,7 @@ async function loadPersistedComposerState(): Promise< return { drafts: {}, stickyModelSelection: null, + stickyRuntimeModeByProjectKey: {}, cloudDrafts: { accountId: null, signedOut: {} }, }; } @@ -365,6 +392,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 +404,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 +667,7 @@ function schedulePersistComposerState(): void { await writePersistedComposerState( appAtomRegistry.get(composerDraftsAtom), appAtomRegistry.get(stickyComposerModelSelectionAtom), + appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom), ); persistRetryNeeded = false; } catch (error) { @@ -661,6 +700,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 +938,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 +1568,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..54c69b75952c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -31,7 +31,7 @@ import { pickComposerMedia, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; -import { scopedThreadKey } from "../lib/scopedEntities"; +import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; import { buildThreadFeed } from "../lib/threadActivity"; import { acknowledgedThreadMessagesAtom } from "./acknowledged-thread-messages"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; @@ -48,6 +48,7 @@ import { removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, + setStickyComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; @@ -586,12 +587,16 @@ export function useThreadComposerState() { const onUpdateRuntimeMode = useCallback( (value: RuntimeMode) => { - if (!selectedThreadKey) { + if (!selectedThreadKey || !selectedThreadShell) { return; } updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + setStickyComposerRuntimeMode( + scopedProjectKey(selectedThreadShell.environmentId, selectedThreadShell.projectId), + value, + ); }, - [selectedThreadKey], + [selectedThreadKey, selectedThreadShell], ); const onUpdateInteractionMode = useCallback( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..b8fd6f1ef77d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -258,6 +258,7 @@ import { preventTerminalCloseShortcut, } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; +import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, @@ -1498,6 +1499,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({ @@ -2021,10 +2024,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({ @@ -2263,10 +2266,15 @@ export default function ChatView(props: ChatViewProps) { const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); + const resolvedRuntimeMode = resolveNewThreadRuntimeMode({ + stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), + configuredRuntimeMode: settings.defaultRuntimeMode, + }); + setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode: resolvedRuntimeMode, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); @@ -2281,12 +2289,15 @@ export default function ChatView(props: ChatViewProps) { draftId, getDraftSession, getDraftSessionByLogicalProjectKey, + getStickyRuntimeMode, isServerThread, navigate, projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, + setStickyRuntimeMode, + settings.defaultRuntimeMode, ], ); @@ -4035,15 +4046,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..01eef318ff96 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..db234bfca2a1 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,36 @@ 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({}); + }); +}); + 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..4db364036b4c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -346,6 +346,10 @@ const PersistedComposerDraftStoreState = Schema.Struct({ Schema.Record(ProviderInstanceId, ModelSelection), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), + /** Last-used runtime/access mode keyed by logical project identity. */ + stickyRuntimeModeByLogicalProjectKey: Schema.optionalKey( + Schema.Record(Schema.String, RuntimeMode), + ), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -477,6 +481,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 +563,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 +740,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 +2255,9 @@ export function partializeComposerDraftStoreState( state.stickyModelSelectionByProvider, ), stickyActiveProvider: state.stickyActiveProvider, + stickyRuntimeModeByLogicalProjectKey: normalizeStickyRuntimeModeByLogicalProjectKey( + state.stickyRuntimeModeByLogicalProjectKey, + ), }; } @@ -2297,6 +2328,9 @@ function normalizeCurrentPersistedComposerDraftStoreState( logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), stickyActiveProvider, + stickyRuntimeModeByLogicalProjectKey: normalizeStickyRuntimeModeByLogicalProjectKey( + normalizedPersistedState.stickyRuntimeModeByLogicalProjectKey, + ), }; } @@ -2524,6 +2558,7 @@ const composerDraftStore = create()( backgroundSubmissionThreadKeys: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + stickyRuntimeModeByLogicalProjectKey: {}, getComposerDraft: (target) => getComposerDraftState(get(), target), getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); @@ -2942,6 +2977,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 +4070,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..124c2b862304 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(() => null), + setStickyRuntimeMode: vi.fn(), applyStickyState: vi.fn(), setDraftThreadContext: vi.fn(), setLogicalProjectDraftThreadId: vi.fn(), setModelSelection: vi.fn(), + setRuntimeMode: vi.fn(), + setInteractionMode: vi.fn(), }; return { @@ -40,6 +44,10 @@ const testState = vi.hoisted(() => { router.state.location.href = "/"; router.navigate.mockClear(); draftStore.setLogicalProjectDraftThreadId.mockClear(); + draftStore.setRuntimeMode.mockClear(); + draftStore.setInteractionMode.mockClear(); + draftStore.setDraftThreadContext.mockClear(); + draftStore.setStickyRuntimeMode.mockClear(); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); @@ -71,7 +79,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 +87,9 @@ vi.mock("@t3tools/shared/threadEnvMode", () => ({ readonly globalDefault: "local" | "worktree"; }) => input.projectFile ?? input.globalDefault, })); +vi.mock("@t3tools/shared/runtimeMode", () => ({ + resolveNewThreadRuntimeMode: () => "full-access", +})); vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, @@ -171,4 +181,27 @@ describe("useNewThreadHandler", () => { expect(testState.router.navigate).not.toHaveBeenCalled(); expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); + + it("syncs composer runtime mode 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.setRuntimeMode).toHaveBeenCalledWith( + "draft-existing", + "full-access", + ); + expect(testState.router.navigate).toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 9b2afb3a61f6..85c584056cc8 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 last-used 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,34 @@ 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 resolvedRuntimeMode = resolveNewThreadRuntimeMode({ + carryRuntimeMode: sameProjectCarryRuntimeMode, + stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), + configuredRuntimeMode: targetServerSettings.defaultRuntimeMode, + }); + // Seed sticky from whatever we apply so fresh starts keep remembering + // even when the user never touched the picker in this session. + setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; @@ -265,10 +296,18 @@ export function useNewThreadHandler() { if (workspaceContext) { setDraftThreadContext(emptyStoredDraftThread.draftId, { ...workspaceContext, - ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + runtimeMode: resolvedRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); } + // Composer draft mode is what the picker reads (it outranks the + // draft-thread session). Keep it in lockstep with the resolved + // mode whenever we resurrect an empty draft, or a stale composer + // override can hide sticky / machine-default updates. + 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 +341,7 @@ export function useNewThreadHandler() { { threadId: emptyStoredDraftThread.threadId, ...workspaceContext, - ...(carryRuntimeMode ? { runtimeMode: carryRuntimeMode } : {}), + runtimeMode: resolvedRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }, ); @@ -415,7 +454,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..282c5a70e8c9 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 last-used 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 + ); +} From 2cf0041fa35f9d6991e5c86b5a85b570b9548759 Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Tue, 8 Sep 2026 19:50:40 +0200 Subject: [PATCH 02/11] fix(web): stop sticky from shadowing Default access Seeding sticky from the machine default on every new thread made later Default access changes, including Auto, never apply. Only carry and explicit picker changes write sticky now. Co-authored-by: Cursor --- apps/web/src/components/ChatView.tsx | 2 -- apps/web/src/hooks/useHandleNewThread.test.ts | 14 ++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 9 ++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b8fd6f1ef77d..a2a3176b16a9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2270,7 +2270,6 @@ export default function ChatView(props: ChatViewProps) { stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), configuredRuntimeMode: settings.defaultRuntimeMode, }); - setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), @@ -2296,7 +2295,6 @@ export default function ChatView(props: ChatViewProps) { routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, - setStickyRuntimeMode, settings.defaultRuntimeMode, ], ); diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 124c2b862304..6d1779c909fc 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -204,4 +204,18 @@ describe("useNewThreadHandler", () => { ); expect(testState.router.navigate).toHaveBeenCalled(); }); + + 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 85c584056cc8..52f4129fbc95 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -195,9 +195,12 @@ export function useNewThreadHandler() { stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), configuredRuntimeMode: targetServerSettings.defaultRuntimeMode, }); - // Seed sticky from whatever we apply so fresh starts keep remembering - // even when the user never touched the picker in this session. - setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); + // 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 hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; From e6ca378823dc4c8ef41fb06695365d0ac7c04ddc Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Tue, 8 Sep 2026 19:55:27 +0200 Subject: [PATCH 03/11] refactor: rename sticky runtime mode to last-used Use lastUsed naming for the per-project remembered access mode so it does not collide with machine defaultRuntimeMode. Keep a read path for the old persisted sticky keys. Co-authored-by: Cursor --- .../threads/new-task-flow-provider.tsx | 12 ++-- .../src/state/use-composer-drafts.test.ts | 38 ++++++++---- apps/mobile/src/state/use-composer-drafts.ts | 54 ++++++++-------- .../src/state/use-thread-composer-state.ts | 4 +- apps/web/src/components/ChatView.tsx | 12 ++-- .../settings/ProjectDefaultsSettings.tsx | 2 +- apps/web/src/composerDraftStore.test.ts | 62 ++++++++++++++----- apps/web/src/composerDraftStore.ts | 41 ++++++------ apps/web/src/hooks/useHandleNewThread.test.ts | 10 +-- apps/web/src/hooks/useHandleNewThread.ts | 18 +++--- packages/contracts/src/settings.ts | 2 +- packages/shared/src/runtimeMode.test.ts | 14 ++--- packages/shared/src/runtimeMode.ts | 6 +- 13 files changed, 163 insertions(+), 112 deletions(-) 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 130e8c136ab3..a51f57d467c5 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -53,11 +53,11 @@ import { scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, setStickyComposerModelSelection, - setStickyComposerRuntimeMode, + setLastUsedComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, useStickyComposerModelSelection, - useStickyComposerRuntimeMode, + useLastUsedComposerRuntimeMode, } from "../../state/use-composer-drafts"; import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { @@ -473,10 +473,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, storedStickyModelSelection, ); - const stickyRuntimeMode = useStickyComposerRuntimeMode(selectedProjectKey); + const lastUsedRuntimeMode = useLastUsedComposerRuntimeMode(selectedProjectKey); const runtimeMode = resolveNewThreadRuntimeMode({ draftRuntimeMode: selectedProjectDraft.runtimeMode, - stickyRuntimeMode, + lastUsedRuntimeMode, configuredRuntimeMode: selectedEnvironmentServerConfig?.settings.defaultRuntimeMode, }); const modelOptions = useMemo( @@ -883,7 +883,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); } if (selectedProjectKey) { - setStickyComposerRuntimeMode(selectedProjectKey, value); + setLastUsedComposerRuntimeMode(selectedProjectKey, value); } }, [selectedProjectDraftKey, selectedProjectKey], @@ -968,7 +968,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ? editingPendingTask?.creation?.projectCwd : selectedProject.workspaceRoot; if (selectedProjectKey) { - setStickyComposerRuntimeMode(selectedProjectKey, runtimeMode); + setLastUsedComposerRuntimeMode(selectedProjectKey, runtimeMode); } return { environmentId: selectedProject.environmentId, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index dc31418ea447..1a8b22d964ac 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -178,9 +178,9 @@ import { setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, setStickyComposerModelSelection, - setStickyComposerRuntimeMode, + setLastUsedComposerRuntimeMode, stickyComposerModelSelectionAtom, - stickyComposerRuntimeModeByProjectKeyAtom, + lastUsedComposerRuntimeModeByProjectKeyAtom, undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; @@ -204,7 +204,7 @@ afterEach(() => { appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); - appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, {}); + appAtomRegistry.set(lastUsedComposerRuntimeModeByProjectKeyAtom, {}); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); composerAttachmentCleanupMocks.releaseUploads.mockReset(); @@ -213,30 +213,44 @@ afterEach(() => { incomingShareStorageMocks.load.mockResolvedValue([]); }); -describe("mobile sticky runtime mode", () => { - it("round-trips sticky runtime modes per project", () => { +describe("mobile last-used runtime mode", () => { + it("round-trips last-used runtime modes per project", () => { expect( decodePersistedComposerState({ schemaVersion: 1, drafts: {}, - stickyRuntimeModeByProjectKey: { + lastUsedRuntimeModeByProjectKey: { "env:project-a": "approval-required", "env:project-b": "auto-accept-edits", }, - }).stickyRuntimeModeByProjectKey, + }).lastUsedRuntimeModeByProjectKey, ).toEqual({ "env:project-a": "approval-required", "env:project-b": "auto-accept-edits", }); }); - it("stores sticky runtime mode updates in memory", () => { - setStickyComposerRuntimeMode("env:project-a", "approval-required"); - expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({ + it("reads legacy stickyRuntimeModeByProjectKey as last-used", () => { + expect( + decodePersistedComposerState({ + schemaVersion: 1, + drafts: {}, + stickyRuntimeModeByProjectKey: { + "env:project-a": "approval-required", + }, + }).lastUsedRuntimeModeByProjectKey, + ).toEqual({ + "env:project-a": "approval-required", + }); + }); + + it("stores last-used runtime mode updates in memory", () => { + setLastUsedComposerRuntimeMode("env:project-a", "approval-required"); + expect(appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom)).toEqual({ "env:project-a": "approval-required", }); - setStickyComposerRuntimeMode("env:project-a", null); - expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({}); + setLastUsedComposerRuntimeMode("env:project-a", null); + expect(appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom)).toEqual({}); }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index d4bf96eef58a..1e78f5f05bb3 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), + lastUsedRuntimeModeByProjectKey: Schema.optional(Schema.Record(Schema.String, RuntimeModeSchema)), + /** @deprecated Renamed to lastUsedRuntimeModeByProjectKey. */ stickyRuntimeModeByProjectKey: Schema.optional(Schema.Record(Schema.String, RuntimeModeSchema)), cloudAccountId: Schema.optional(Schema.String), signedOutDrafts: Schema.optional( @@ -159,9 +161,9 @@ export const stickyComposerModelSelectionAtom = Atom.make Atom.withLabel("mobile:sticky-composer-model-selection"), ); -export const stickyComposerRuntimeModeByProjectKeyAtom = Atom.make< +export const lastUsedComposerRuntimeModeByProjectKeyAtom = Atom.make< Partial> ->({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-runtime-mode-by-project")); +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:last-used-composer-runtime-mode-by-project")); interface SignedOutDrafts { readonly drafts: Record; @@ -278,7 +280,7 @@ export function migrateLegacyNewTaskDraft( export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; - readonly stickyRuntimeModeByProjectKey: Partial>; + readonly lastUsedRuntimeModeByProjectKey: Partial>; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); @@ -313,8 +315,8 @@ export function decodePersistedComposerState(value: unknown): { .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, - stickyRuntimeModeByProjectKey: normalizeStickyRuntimeModeByProjectKey( - parsed.stickyRuntimeModeByProjectKey, + lastUsedRuntimeModeByProjectKey: normalizeLastUsedRuntimeModeByProjectKey( + parsed.lastUsedRuntimeModeByProjectKey ?? parsed.stickyRuntimeModeByProjectKey, ), cloudDrafts: { accountId: parsed.cloudAccountId ?? null, @@ -337,7 +339,7 @@ export function decodePersistedComposerState(value: unknown): { }; } -function normalizeStickyRuntimeModeByProjectKey( +function normalizeLastUsedRuntimeModeByProjectKey( value: Partial> | undefined, ): Partial> { if (!value) { @@ -371,7 +373,7 @@ async function loadPersistedComposerState(): Promise< return { drafts: {}, stickyModelSelection: null, - stickyRuntimeModeByProjectKey: {}, + lastUsedRuntimeModeByProjectKey: {}, cloudDrafts: { accountId: null, signedOut: {} }, }; } @@ -392,8 +394,8 @@ async function loadPersistedComposerState(): Promise< async function writePersistedComposerState( drafts: Record, stickyModelSelection: ModelSelection | null, - stickyRuntimeModeByProjectKey: Partial> = appAtomRegistry.get( - stickyComposerRuntimeModeByProjectKeyAtom, + lastUsedRuntimeModeByProjectKey: Partial> = appAtomRegistry.get( + lastUsedComposerRuntimeModeByProjectKeyAtom, ), cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), ): Promise { @@ -404,8 +406,8 @@ async function writePersistedComposerState( const nonEmptyDrafts = Object.fromEntries( Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); - const stickyRuntimeModes = Object.fromEntries( - Object.entries(stickyRuntimeModeByProjectKey).filter( + const lastUsedRuntimeModes = Object.fromEntries( + Object.entries(lastUsedRuntimeModeByProjectKey).filter( (entry): entry is [string, RuntimeMode] => entry[1] !== undefined, ), ); @@ -413,8 +415,8 @@ async function writePersistedComposerState( schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), - ...(Object.keys(stickyRuntimeModes).length > 0 - ? { stickyRuntimeModeByProjectKey: stickyRuntimeModes } + ...(Object.keys(lastUsedRuntimeModes).length > 0 + ? { lastUsedRuntimeModeByProjectKey: lastUsedRuntimeModes } : {}), ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), ...(Object.keys(cloudDrafts.signedOut).length > 0 @@ -667,7 +669,7 @@ function schedulePersistComposerState(): void { await writePersistedComposerState( appAtomRegistry.get(composerDraftsAtom), appAtomRegistry.get(stickyComposerModelSelectionAtom), - appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom), + appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom), ); persistRetryNeeded = false; } catch (error) { @@ -700,10 +702,10 @@ 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, + if (Object.keys(persisted.lastUsedRuntimeModeByProjectKey).length > 0) { + const current = appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom); + appAtomRegistry.set(lastUsedComposerRuntimeModeByProjectKeyAtom, { + ...persisted.lastUsedRuntimeModeByProjectKey, ...current, }); } @@ -938,15 +940,15 @@ export function setStickyComposerModelSelection(modelSelection: ModelSelection): schedulePersistComposerState(); } -export function getStickyComposerRuntimeMode(projectKey: string): RuntimeMode | null { +export function getLastUsedComposerRuntimeMode(projectKey: string): RuntimeMode | null { const key = projectKey.trim(); if (key.length === 0) { return null; } - return appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)[key] ?? null; + return appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom)[key] ?? null; } -export function setStickyComposerRuntimeMode( +export function setLastUsedComposerRuntimeMode( projectKey: string, runtimeMode: RuntimeMode | null | undefined, ): void { @@ -954,7 +956,7 @@ export function setStickyComposerRuntimeMode( if (key.length === 0) { return; } - const current = appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom); + const current = appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom); const nextMode = runtimeMode ?? null; if ((current[key] ?? null) === nextMode) { return; @@ -965,7 +967,7 @@ export function setStickyComposerRuntimeMode( } else { next[key] = nextMode; } - appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, next); + appAtomRegistry.set(lastUsedComposerRuntimeModeByProjectKeyAtom, next); schedulePersistComposerState(); } @@ -1569,13 +1571,13 @@ export function useStickyComposerModelSelection(): ModelSelection | null { return selection; } -export function useStickyComposerRuntimeMode(projectKey: string | null): RuntimeMode | null { - const stickyByProject = useAtomValue(stickyComposerRuntimeModeByProjectKeyAtom); +export function useLastUsedComposerRuntimeMode(projectKey: string | null): RuntimeMode | null { + const lastUsedByProject = useAtomValue(lastUsedComposerRuntimeModeByProjectKeyAtom); useEffect(() => { ensureComposerDraftsLoaded(); }, []); if (!projectKey) { return null; } - return stickyByProject[projectKey] ?? null; + return lastUsedByProject[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 54c69b75952c..101ce2a731b0 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -48,7 +48,7 @@ import { removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, - setStickyComposerRuntimeMode, + setLastUsedComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; @@ -591,7 +591,7 @@ export function useThreadComposerState() { return; } updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); - setStickyComposerRuntimeMode( + setLastUsedComposerRuntimeMode( scopedProjectKey(selectedThreadShell.environmentId, selectedThreadShell.projectId), value, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a2a3176b16a9..a70f6a659e7a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1499,8 +1499,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 setLastUsedRuntimeMode = useComposerDraftStore((store) => store.setLastUsedRuntimeMode); + const getLastUsedRuntimeMode = useComposerDraftStore((store) => store.getLastUsedRuntimeMode); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const citationLocation = useLocation({ @@ -2267,7 +2267,7 @@ export default function ChatView(props: ChatViewProps) { const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); const resolvedRuntimeMode = resolveNewThreadRuntimeMode({ - stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), + lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), configuredRuntimeMode: settings.defaultRuntimeMode, }); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { @@ -2288,7 +2288,7 @@ export default function ChatView(props: ChatViewProps) { draftId, getDraftSession, getDraftSessionByLogicalProjectKey, - getStickyRuntimeMode, + getLastUsedRuntimeMode, isServerThread, navigate, projectGroupingSettings, @@ -4045,7 +4045,7 @@ export default function ChatView(props: ChatViewProps) { setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } if (activeLogicalProjectKey) { - setStickyRuntimeMode(activeLogicalProjectKey, mode); + setLastUsedRuntimeMode(activeLogicalProjectKey, mode); } scheduleComposerFocus(); }, @@ -4057,7 +4057,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, - setStickyRuntimeMode, + setLastUsedRuntimeMode, ], ); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 01eef318ff96..7b0d47ea9e56 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -328,7 +328,7 @@ export function ProjectDefaultsSettings({ { logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, - stickyRuntimeModeByLogicalProjectKey: {}, + lastUsedRuntimeModeByLogicalProjectKey: {}, }); }); @@ -788,7 +788,7 @@ describe("composerDraftStore terminal contexts", () => { logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, - stickyRuntimeModeByLogicalProjectKey: {}, + lastUsedRuntimeModeByLogicalProjectKey: {}, }); }); @@ -2549,33 +2549,65 @@ describe("composerDraftStore provider-scoped option updates", () => { }); }); -describe("composerDraftStore sticky runtime mode", () => { +describe("composerDraftStore last-used runtime mode", () => { beforeEach(() => { resetComposerDraftStore(); }); - it("stores a sticky runtime mode per logical project", () => { + it("stores a last-used runtime mode per logical project", () => { const store = useComposerDraftStore.getState(); - store.setStickyRuntimeMode("project-a", "approval-required"); - store.setStickyRuntimeMode("project-b", "auto-accept-edits"); + store.setLastUsedRuntimeMode("project-a", "approval-required"); + store.setLastUsedRuntimeMode("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({ + expect(store.getLastUsedRuntimeMode("project-a")).toBe("approval-required"); + expect(store.getLastUsedRuntimeMode("project-b")).toBe("auto-accept-edits"); + expect(useComposerDraftStore.getState().lastUsedRuntimeModeByLogicalProjectKey).toEqual({ "project-a": "approval-required", "project-b": "auto-accept-edits", }); }); - it("clears sticky runtime mode for a project", () => { + it("clears last-used runtime mode for a project", () => { const store = useComposerDraftStore.getState(); - store.setStickyRuntimeMode("project-a", "approval-required"); - store.setStickyRuntimeMode("project-a", null); + store.setLastUsedRuntimeMode("project-a", "approval-required"); + store.setLastUsedRuntimeMode("project-a", null); - expect(store.getStickyRuntimeMode("project-a")).toBeNull(); - expect(useComposerDraftStore.getState().stickyRuntimeModeByLogicalProjectKey).toEqual({}); + expect(store.getLastUsedRuntimeMode("project-a")).toBeNull(); + expect(useComposerDraftStore.getState().lastUsedRuntimeModeByLogicalProjectKey).toEqual({}); + }); + + it("migrates legacy stickyRuntimeModeByLogicalProjectKey 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: {}, + stickyRuntimeModeByLogicalProjectKey: { + "project-legacy": "auto", + }, + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + await useComposerDraftStore.persist.rehydrate(); + + expect(useComposerDraftStore.getState().lastUsedRuntimeModeByLogicalProjectKey).toEqual({ + "project-legacy": "auto", + }); + expect(useComposerDraftStore.getState().getLastUsedRuntimeMode("project-legacy")).toBe( + "auto", + ); + } finally { + vi.useRealTimers(); + await useComposerDraftStore.persist.clearStorage(); + } }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 4db364036b4c..0b916393c475 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -307,6 +307,8 @@ type LegacyV2StoreFields = { draftThreadsByThreadKey?: Record | null; projectDraftThreadKeyByProjectKey?: Record | null; logicalProjectDraftThreadKeyByLogicalProjectKey?: Record | null; + /** @deprecated Renamed to lastUsedRuntimeModeByLogicalProjectKey. */ + stickyRuntimeModeByLogicalProjectKey?: Record | null; }; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & @@ -347,7 +349,7 @@ const PersistedComposerDraftStoreState = Schema.Struct({ ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), /** Last-used runtime/access mode keyed by logical project identity. */ - stickyRuntimeModeByLogicalProjectKey: Schema.optionalKey( + lastUsedRuntimeModeByLogicalProjectKey: Schema.optionalKey( Schema.Record(Schema.String, RuntimeMode), ), }); @@ -481,7 +483,7 @@ interface ComposerDraftStoreState { backgroundSubmissionThreadKeys: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderInstanceId | null; - stickyRuntimeModeByLogicalProjectKey: Partial>; + lastUsedRuntimeModeByLogicalProjectKey: 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. */ @@ -563,8 +565,8 @@ interface ComposerDraftStoreState { finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; - getStickyRuntimeMode: (logicalProjectKey: string) => RuntimeMode | null; - setStickyRuntimeMode: ( + getLastUsedRuntimeMode: (logicalProjectKey: string) => RuntimeMode | null; + setLastUsedRuntimeMode: ( logicalProjectKey: string, runtimeMode: RuntimeMode | null | undefined, ) => void; @@ -740,7 +742,7 @@ function compactModelSelectionByProvider( return Object.fromEntries(entries) as DeepMutable>; } -function normalizeStickyRuntimeModeByLogicalProjectKey( +function normalizeLastUsedRuntimeModeByLogicalProjectKey( value: unknown, ): Record { if (!value || typeof value !== "object") { @@ -763,7 +765,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze()( backgroundSubmissionThreadKeys: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, - stickyRuntimeModeByLogicalProjectKey: {}, + lastUsedRuntimeModeByLogicalProjectKey: {}, getComposerDraft: (target) => getComposerDraftState(get(), target), getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); @@ -2977,31 +2980,31 @@ const composerDraftStore = create()( }; }); }, - getStickyRuntimeMode: (logicalProjectKey) => { + getLastUsedRuntimeMode: (logicalProjectKey) => { const key = logicalProjectDraftKey(logicalProjectKey); if (key.length === 0) { return null; } - return get().stickyRuntimeModeByLogicalProjectKey[key] ?? null; + return get().lastUsedRuntimeModeByLogicalProjectKey[key] ?? null; }, - setStickyRuntimeMode: (logicalProjectKey, runtimeMode) => { + setLastUsedRuntimeMode: (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; + const current = state.lastUsedRuntimeModeByLogicalProjectKey[key] ?? null; if (current === nextRuntimeMode) { return state; } - const nextMap = { ...state.stickyRuntimeModeByLogicalProjectKey }; + const nextMap = { ...state.lastUsedRuntimeModeByLogicalProjectKey }; if (nextRuntimeMode === null) { delete nextMap[key]; } else { nextMap[key] = nextRuntimeMode; } - return { stickyRuntimeModeByLogicalProjectKey: nextMap }; + return { lastUsedRuntimeModeByLogicalProjectKey: nextMap }; }); }, applyStickyState: (threadRef) => { @@ -4070,8 +4073,8 @@ const composerDraftStore = create()( normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, - stickyRuntimeModeByLogicalProjectKey: - normalizedPersisted.stickyRuntimeModeByLogicalProjectKey ?? {}, + lastUsedRuntimeModeByLogicalProjectKey: + normalizedPersisted.lastUsedRuntimeModeByLogicalProjectKey ?? {}, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6d1779c909fc..1e8c83b50b85 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -23,8 +23,8 @@ const testState = vi.hoisted(() => { getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), getDraftSession: vi.fn(() => null), getDraftThread: vi.fn(() => null), - getStickyRuntimeMode: vi.fn(() => null), - setStickyRuntimeMode: vi.fn(), + getLastUsedRuntimeMode: vi.fn(() => null), + setLastUsedRuntimeMode: vi.fn(), applyStickyState: vi.fn(), setDraftThreadContext: vi.fn(), setLogicalProjectDraftThreadId: vi.fn(), @@ -47,7 +47,7 @@ const testState = vi.hoisted(() => { draftStore.setRuntimeMode.mockClear(); draftStore.setInteractionMode.mockClear(); draftStore.setDraftThreadContext.mockClear(); - draftStore.setStickyRuntimeMode.mockClear(); + draftStore.setLastUsedRuntimeMode.mockClear(); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); @@ -205,7 +205,7 @@ describe("useNewThreadHandler", () => { expect(testState.router.navigate).toHaveBeenCalled(); }); - it("does not seed sticky from the machine default alone", async () => { + it("does not seed last-used from the machine default alone", async () => { testState.reset(null); const openThread = useNewThreadHandler(); const pendingOpen = openThread({ @@ -216,6 +216,6 @@ describe("useNewThreadHandler", () => { testState.completeProjectFileRead(null); await pendingOpen; - expect(testState.draftStore.setStickyRuntimeMode).not.toHaveBeenCalled(); + expect(testState.draftStore.setLastUsedRuntimeMode).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 52f4129fbc95..b57731a2b572 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -87,8 +87,8 @@ export function useNewThreadHandler() { getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, - getStickyRuntimeMode, - setStickyRuntimeMode, + getLastUsedRuntimeMode, + setLastUsedRuntimeMode, applyStickyState, setDraftThreadContext, setLogicalProjectDraftThreadId, @@ -101,9 +101,9 @@ export function useNewThreadHandler() { const currentRouteTarget = getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being // viewed only when that source belongs to the same logical project. - // Otherwise the project's sticky last-used mode wins over the hardcoded + // Otherwise the project's last-used 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. + // interaction/plan mode carries independently and is not last-used. // Branch, worktree, and env mode come from configured defaults unless // the caller passes them explicitly. const carrySourceShell = @@ -192,14 +192,14 @@ export function useNewThreadHandler() { carrySourceLogicalProjectKey === logicalProjectKey ? carryRuntimeMode : null; const resolvedRuntimeMode = resolveNewThreadRuntimeMode({ carryRuntimeMode: sameProjectCarryRuntimeMode, - stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), + lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), configuredRuntimeMode: targetServerSettings.defaultRuntimeMode, }); - // Only promote carry into sticky. Seeding from the machine default would + // Only promote carry into last-used. Seeding from the machine default would // permanently shadow later Default access changes (including Auto). - // Explicit composer picks update sticky in ChatView. + // Explicit composer picks update last-used in ChatView. if (sameProjectCarryRuntimeMode != null) { - setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); + setLastUsedRuntimeMode(logicalProjectKey, resolvedRuntimeMode); } const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; @@ -306,7 +306,7 @@ export function useNewThreadHandler() { // Composer draft mode is what the picker reads (it outranks the // draft-thread session). Keep it in lockstep with the resolved // mode whenever we resurrect an empty draft, or a stale composer - // override can hide sticky / machine-default updates. + // override can hide last-used / machine-default updates. setRuntimeMode(emptyStoredDraftThread.draftId, resolvedRuntimeMode); if (carryInteractionMode) { setInteractionMode(emptyStoredDraftThread.draftId, carryInteractionMode); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 282c5a70e8c9..55f8ccf71bc5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1013,7 +1013,7 @@ export const ServerSettings = Schema.Struct({ ), /** * Preferred access mode for new threads when nothing carries from the - * current view and the client has no sticky last-used mode for the project. + * current view and the client has no last-used mode for the project. */ defaultRuntimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), diff --git a/packages/shared/src/runtimeMode.test.ts b/packages/shared/src/runtimeMode.test.ts index e317b22deece..0951f5f7c9a0 100644 --- a/packages/shared/src/runtimeMode.test.ts +++ b/packages/shared/src/runtimeMode.test.ts @@ -3,37 +3,37 @@ 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", () => { + it("prefers an explicit draft pick over carry, last-used, and configured", () => { expect( resolveNewThreadRuntimeMode({ draftRuntimeMode: "approval-required", carryRuntimeMode: "auto-accept-edits", - stickyRuntimeMode: "full-access", + lastUsedRuntimeMode: "full-access", configuredRuntimeMode: "auto", }), ).toBe("approval-required"); }); - it("prefers same-project carry over sticky and configured", () => { + it("prefers same-project carry over last-used and configured", () => { expect( resolveNewThreadRuntimeMode({ carryRuntimeMode: "auto-accept-edits", - stickyRuntimeMode: "approval-required", + lastUsedRuntimeMode: "approval-required", configuredRuntimeMode: "auto", }), ).toBe("auto-accept-edits"); }); - it("uses project sticky when nothing carries", () => { + it("uses project last-used when nothing carries", () => { expect( resolveNewThreadRuntimeMode({ - stickyRuntimeMode: "approval-required", + lastUsedRuntimeMode: "approval-required", configuredRuntimeMode: "auto", }), ).toBe("approval-required"); }); - it("uses the configured default when sticky is absent", () => { + it("uses the configured default when last-used is absent", () => { expect( resolveNewThreadRuntimeMode({ configuredRuntimeMode: "auto", diff --git a/packages/shared/src/runtimeMode.ts b/packages/shared/src/runtimeMode.ts index e4ecdaaa8c71..5314155fc568 100644 --- a/packages/shared/src/runtimeMode.ts +++ b/packages/shared/src/runtimeMode.ts @@ -3,7 +3,7 @@ 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 + * explicit draft pick > same-project carry > project last-used > configured * machine/project default > hardcoded default. * * Interaction/plan mode stays out of this resolver on purpose. @@ -11,13 +11,13 @@ import { DEFAULT_RUNTIME_MODE, type RuntimeMode } from "@t3tools/contracts"; export function resolveNewThreadRuntimeMode(sources: { readonly draftRuntimeMode?: RuntimeMode | null; readonly carryRuntimeMode?: RuntimeMode | null; - readonly stickyRuntimeMode?: RuntimeMode | null; + readonly lastUsedRuntimeMode?: RuntimeMode | null; readonly configuredRuntimeMode?: RuntimeMode | null; }): RuntimeMode { return ( sources.draftRuntimeMode ?? sources.carryRuntimeMode ?? - sources.stickyRuntimeMode ?? + sources.lastUsedRuntimeMode ?? sources.configuredRuntimeMode ?? DEFAULT_RUNTIME_MODE ); From dc470f7541b6ad20acb671096912ecfb9ff081cc Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Tue, 8 Sep 2026 20:31:03 +0200 Subject: [PATCH 04/11] fix: honor draft runtime mode and logical project last-used keys Keep mobile last-used access mode on logical project identity, stop persisting implicit defaults as last-used on submit, and preserve an empty draft's explicit composer mode when resurrecting on web. Co-authored-by: Cursor --- .../threads/new-task-flow-provider.tsx | 17 +++--- .../src/state/use-thread-composer-state.ts | 12 +++-- apps/web/src/hooks/useHandleNewThread.test.ts | 54 +++++++++++++++++-- apps/web/src/hooks/useHandleNewThread.ts | 43 +++++++++------ 4 files changed, 95 insertions(+), 31 deletions(-) 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 a51f57d467c5..1c67e94e63b2 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -35,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"; @@ -473,7 +474,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, storedStickyModelSelection, ); - const lastUsedRuntimeMode = useLastUsedComposerRuntimeMode(selectedProjectKey); + // Last-used 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 lastUsedRuntimeMode = useLastUsedComposerRuntimeMode(selectedLogicalProjectKey); const runtimeMode = resolveNewThreadRuntimeMode({ draftRuntimeMode: selectedProjectDraft.runtimeMode, lastUsedRuntimeMode, @@ -882,11 +888,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (selectedProjectDraftKey) { updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); } - if (selectedProjectKey) { - setLastUsedComposerRuntimeMode(selectedProjectKey, value); + if (selectedLogicalProjectKey) { + setLastUsedComposerRuntimeMode(selectedLogicalProjectKey, value); } }, - [selectedProjectDraftKey, selectedProjectKey], + [selectedLogicalProjectKey, selectedProjectDraftKey], ); const setInteractionMode = useCallback( (value: ProviderInteractionMode) => { @@ -967,9 +973,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projectCwd = usingPendingSnapshot ? editingPendingTask?.creation?.projectCwd : selectedProject.workspaceRoot; - if (selectedProjectKey) { - setLastUsedComposerRuntimeMode(selectedProjectKey, runtimeMode); - } return { environmentId: selectedProject.environmentId, threadId: ThreadId.make(metadata.threadId), diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 101ce2a731b0..73911d287a21 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -31,7 +31,8 @@ import { pickComposerMedia, } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; -import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; +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"; import { appendPendingThreadMessages } from "../features/threads/pending-thread-feed"; @@ -63,6 +64,7 @@ import { composerAttachmentUploadBlockReason, composerAttachmentUploadsAtom, } from "./composer-attachment-uploads"; +import { useMobileProjectGroupingSettings } from "./project-grouping"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -106,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); @@ -587,16 +591,16 @@ export function useThreadComposerState() { const onUpdateRuntimeMode = useCallback( (value: RuntimeMode) => { - if (!selectedThreadKey || !selectedThreadShell) { + if (!selectedThreadKey || !selectedThreadProject) { return; } updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); setLastUsedComposerRuntimeMode( - scopedProjectKey(selectedThreadShell.environmentId, selectedThreadShell.projectId), + deriveLogicalProjectKeyFromSettings(selectedThreadProject, projectGroupingSettings), value, ); }, - [selectedThreadKey, selectedThreadShell], + [projectGroupingSettings, selectedThreadKey, selectedThreadProject], ); const onUpdateInteractionMode = useCallback( diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 1e8c83b50b85..1ee4122cf50f 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -43,6 +43,10 @@ const testState = vi.hoisted(() => { storedDraft = nextStoredDraft; router.state.location.href = "/"; router.navigate.mockClear(); + draftStore.getComposerDraft.mockReset(); + draftStore.getComposerDraft.mockImplementation(() => ({})); + draftStore.getLastUsedRuntimeMode.mockReset(); + draftStore.getLastUsedRuntimeMode.mockImplementation(() => null); draftStore.setLogicalProjectDraftThreadId.mockClear(); draftStore.setRuntimeMode.mockClear(); draftStore.setInteractionMode.mockClear(); @@ -88,7 +92,17 @@ vi.mock("@t3tools/shared/threadEnvMode", () => ({ }) => input.projectFile ?? input.globalDefault, })); vi.mock("@t3tools/shared/runtimeMode", () => ({ - resolveNewThreadRuntimeMode: () => "full-access", + resolveNewThreadRuntimeMode: (sources: { + readonly draftRuntimeMode?: string | null; + readonly carryRuntimeMode?: string | null; + readonly lastUsedRuntimeMode?: string | null; + readonly configuredRuntimeMode?: string | null; + }) => + sources.draftRuntimeMode ?? + sources.carryRuntimeMode ?? + sources.lastUsedRuntimeMode ?? + sources.configuredRuntimeMode ?? + "full-access", })); vi.mock("@tanstack/react-router", () => ({ useParams: () => null, @@ -182,7 +196,7 @@ describe("useNewThreadHandler", () => { expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); }); - it("syncs composer runtime mode when resurrecting an empty draft", async () => { + it("writes resolved runtime mode onto the draft session when resurrecting an empty draft", async () => { testState.reset({ draftId: "draft-existing", environmentId: "environment-ssh", @@ -198,13 +212,45 @@ describe("useNewThreadHandler", () => { testState.completeProjectFileRead(null); await pendingOpen; - expect(testState.draftStore.setRuntimeMode).toHaveBeenCalledWith( + expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith( "draft-existing", - "full-access", + expect.objectContaining({ runtimeMode: "full-access" }), ); + // No composer pick yet — do not seed last-used 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.getLastUsedRuntimeMode.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 last-used from the machine default alone", async () => { testState.reset(null); const openThread = useNewThreadHandler(); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index b57731a2b572..ed5025361cdf 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -190,17 +190,6 @@ export function useNewThreadHandler() { : (carrySourceDraft?.logicalProjectKey ?? null); const sameProjectCarryRuntimeMode = carrySourceLogicalProjectKey === logicalProjectKey ? carryRuntimeMode : null; - const resolvedRuntimeMode = resolveNewThreadRuntimeMode({ - carryRuntimeMode: sameProjectCarryRuntimeMode, - lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), - configuredRuntimeMode: targetServerSettings.defaultRuntimeMode, - }); - // Only promote carry into last-used. Seeding from the machine default would - // permanently shadow later Default access changes (including Auto). - // Explicit composer picks update last-used in ChatView. - if (sameProjectCarryRuntimeMode != null) { - setLastUsedRuntimeMode(logicalProjectKey, resolvedRuntimeMode); - } const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; @@ -230,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 last-used / 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, + lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), + configuredRuntimeMode: targetServerSettings.defaultRuntimeMode, + }); + // Only promote carry into last-used. Seeding from the machine default would + // permanently shadow later Default access changes (including Auto). + // Explicit composer picks update last-used in ChatView. + if (sameProjectCarryRuntimeMode != null) { + setLastUsedRuntimeMode(logicalProjectKey, resolvedRuntimeMode); + } const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" ? getDraftThread(currentRouteTarget.threadRef) @@ -303,11 +312,13 @@ export function useNewThreadHandler() { ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); } - // Composer draft mode is what the picker reads (it outranks the - // draft-thread session). Keep it in lockstep with the resolved - // mode whenever we resurrect an empty draft, or a stale composer - // override can hide last-used / machine-default updates. - setRuntimeMode(emptyStoredDraftThread.draftId, resolvedRuntimeMode); + // 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 last-used / 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); } From c8d3ef9e352b82e972ada325004b85eda2bb134f Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Tue, 8 Sep 2026 20:50:56 +0200 Subject: [PATCH 05/11] fix(web): widen last-used runtime mode mock return type The resurrect test stubs a concrete last-used mode, but the hoisted mock was inferred as null-only and failed apps/web typecheck in CI. Co-authored-by: Cursor --- apps/web/src/hooks/useHandleNewThread.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 1ee4122cf50f..20bfa8926109 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -23,7 +23,7 @@ const testState = vi.hoisted(() => { getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), getDraftSession: vi.fn(() => null), getDraftThread: vi.fn(() => null), - getLastUsedRuntimeMode: vi.fn(() => null), + getLastUsedRuntimeMode: vi.fn((): string | null => null), setLastUsedRuntimeMode: vi.fn(), applyStickyState: vi.fn(), setDraftThreadContext: vi.fn(), From 8f8bf59ce38a96b9edc57cf6835fef6e2b1c5d82 Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Wed, 9 Sep 2026 01:28:45 +0200 Subject: [PATCH 06/11] fix(mobile): allow runtime mode draft updates without project metadata Keep composer draft updates gated only on the selected thread key so the picker still works when project metadata is briefly missing; last-used persistence still requires the project. Co-authored-by: Cursor --- apps/mobile/src/state/use-thread-composer-state.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 73911d287a21..1390d414a705 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -591,10 +591,13 @@ export function useThreadComposerState() { const onUpdateRuntimeMode = useCallback( (value: RuntimeMode) => { - if (!selectedThreadKey || !selectedThreadProject) { + if (!selectedThreadKey) { return; } updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + if (!selectedThreadProject) { + return; + } setLastUsedComposerRuntimeMode( deriveLogicalProjectKeyFromSettings(selectedThreadProject, projectGroupingSettings), value, From b29d2f9b94b980f63385ee5b2cf40cf478c6e0d0 Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Wed, 9 Sep 2026 01:40:27 +0200 Subject: [PATCH 07/11] fix(web): carry viewed access mode into new pull-request drafts Minting a PR draft from ChatView only consulted last-used and the machine default, so a supervised thread could land on full-access when last-used was empty. Prefer the composer override, then the viewed thread mode. Co-authored-by: Cursor --- .../web/src/components/ChatView.logic.test.ts | 19 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 20 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 8 ++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 821044f2e774..b7d229c481c5 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", + lastUsedRuntimeMode: null, + configuredRuntimeMode: "full-access", + }), + ).toBe("approval-required"); + expect( + resolveNewPullRequestDraftRuntimeMode({ + composerRuntimeMode: "auto-accept-edits", + viewedThreadRuntimeMode: "approval-required", + lastUsedRuntimeMode: "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..0e374bd39dc1 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 last-used and + * the machine default — same carry precedence as new-thread creation. + */ +export function resolveNewPullRequestDraftRuntimeMode(sources: { + readonly composerRuntimeMode?: RuntimeMode | null; + readonly viewedThreadRuntimeMode?: RuntimeMode | null; + readonly lastUsedRuntimeMode?: RuntimeMode | null; + readonly configuredRuntimeMode?: RuntimeMode | null; +}): RuntimeMode { + return resolveNewThreadRuntimeMode({ + carryRuntimeMode: sources.composerRuntimeMode ?? sources.viewedThreadRuntimeMode ?? null, + lastUsedRuntimeMode: sources.lastUsedRuntimeMode, + configuredRuntimeMode: sources.configuredRuntimeMode, + }); +} + 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 1c200d6515f5..5aa231c2f83d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -258,7 +258,6 @@ import { preventTerminalCloseShortcut, } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; -import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, @@ -410,6 +409,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + resolveNewPullRequestDraftRuntimeMode, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, @@ -2268,7 +2268,9 @@ export default function ChatView(props: ChatViewProps) { const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); - const resolvedRuntimeMode = resolveNewThreadRuntimeMode({ + const resolvedRuntimeMode = resolveNewPullRequestDraftRuntimeMode({ + composerRuntimeMode, + viewedThreadRuntimeMode: activeThread?.runtimeMode, lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), configuredRuntimeMode: settings.defaultRuntimeMode, }); @@ -2287,6 +2289,8 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, + activeThread?.runtimeMode, + composerRuntimeMode, draftId, getDraftSession, getDraftSessionByLogicalProjectKey, From f91e14af6bbcd02e699853c209c06d4f41e564dd Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Wed, 9 Sep 2026 01:46:14 +0200 Subject: [PATCH 08/11] fix(web): satisfy exactOptionalPropertyTypes for PR draft mode Coerce optional runtime-mode inputs to null before passing them into resolvers so desktop/web typecheck stops failing under exactOptionalPropertyTypes. Co-authored-by: Cursor --- apps/web/src/components/ChatView.logic.ts | 4 ++-- apps/web/src/components/ChatView.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0e374bd39dc1..606439d84c45 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -644,8 +644,8 @@ export function resolveNewPullRequestDraftRuntimeMode(sources: { }): RuntimeMode { return resolveNewThreadRuntimeMode({ carryRuntimeMode: sources.composerRuntimeMode ?? sources.viewedThreadRuntimeMode ?? null, - lastUsedRuntimeMode: sources.lastUsedRuntimeMode, - configuredRuntimeMode: sources.configuredRuntimeMode, + lastUsedRuntimeMode: sources.lastUsedRuntimeMode ?? null, + configuredRuntimeMode: sources.configuredRuntimeMode ?? null, }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5aa231c2f83d..b3b52953ccc8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2270,9 +2270,9 @@ export default function ChatView(props: ChatViewProps) { const nextThreadId = newThreadId(); const resolvedRuntimeMode = resolveNewPullRequestDraftRuntimeMode({ composerRuntimeMode, - viewedThreadRuntimeMode: activeThread?.runtimeMode, + viewedThreadRuntimeMode: activeThread?.runtimeMode ?? null, lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), - configuredRuntimeMode: settings.defaultRuntimeMode, + configuredRuntimeMode: settings.defaultRuntimeMode ?? null, }); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, From a9f729b42795a2431d124c99d8267153e51f0941 Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Wed, 9 Sep 2026 02:02:27 +0200 Subject: [PATCH 09/11] fix(web): cancel pending rAF stubs before unstubbing globals Pierre's worker pool broadcasts on requestAnimationFrame. The readiness test mapped that to setImmediate, then removed cancelAnimationFrame in afterEach while a deferred broadcast could still run, failing the web suite with an unhandled ReferenceError. Co-authored-by: Cursor --- .../files/fileEditorLanguageReadiness.test.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts index 520c0fa82d0e..884195627bb4 100644 --- a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts +++ b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts @@ -40,6 +40,7 @@ const source = "export const View = () =>
Ready
;"; let pool: WorkerPoolManager; let renderer: FileRenderer; let terminationPromises: Promise[]; +const pendingAnimationFrames = new Set(); class WorkerTransport { private readonly worker = new NodeWorkerThreads.Worker( @@ -96,10 +97,23 @@ function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: beforeEach(async () => { terminationPromises = []; - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => - setImmediate(() => callback(0)), - ); - vi.stubGlobal("cancelAnimationFrame", clearImmediate); + pendingAnimationFrames.clear(); + // Pierre's worker pool broadcasts on rAF. Track the Node immediates so + // teardown can cancel them before unstubbing cancelAnimationFrame — otherwise + // a late broadcast throws ReferenceError and fails the whole web suite. + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const handle = setImmediate(() => { + pendingAnimationFrames.delete(handle); + callback(0); + }); + pendingAnimationFrames.add(handle); + return handle as unknown as number; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => { + const handle = id as unknown as NodeJS.Immediate; + pendingAnimationFrames.delete(handle); + clearImmediate(handle); + }); vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); await disposeHighlighter(); pool = new WorkerPoolManager( @@ -116,6 +130,10 @@ afterEach(async () => { pool?.terminate(); await Promise.all(terminationPromises); await disposeHighlighter(); + for (const handle of pendingAnimationFrames) { + clearImmediate(handle); + } + pendingAnimationFrames.clear(); vi.unstubAllGlobals(); }); From 02a24564dbd4044552296111d81759c6dd90d40c Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Wed, 9 Sep 2026 02:08:21 +0200 Subject: [PATCH 10/11] Revert "fix(web): cancel pending rAF stubs before unstubbing globals" This reverts commit a9f729b42795a2431d124c99d8267153e51f0941. --- .../files/fileEditorLanguageReadiness.test.ts | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts index 884195627bb4..520c0fa82d0e 100644 --- a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts +++ b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts @@ -40,7 +40,6 @@ const source = "export const View = () =>
Ready
;"; let pool: WorkerPoolManager; let renderer: FileRenderer; let terminationPromises: Promise[]; -const pendingAnimationFrames = new Set(); class WorkerTransport { private readonly worker = new NodeWorkerThreads.Worker( @@ -97,23 +96,10 @@ function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: beforeEach(async () => { terminationPromises = []; - pendingAnimationFrames.clear(); - // Pierre's worker pool broadcasts on rAF. Track the Node immediates so - // teardown can cancel them before unstubbing cancelAnimationFrame — otherwise - // a late broadcast throws ReferenceError and fails the whole web suite. - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - const handle = setImmediate(() => { - pendingAnimationFrames.delete(handle); - callback(0); - }); - pendingAnimationFrames.add(handle); - return handle as unknown as number; - }); - vi.stubGlobal("cancelAnimationFrame", (id: number) => { - const handle = id as unknown as NodeJS.Immediate; - pendingAnimationFrames.delete(handle); - clearImmediate(handle); - }); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setImmediate(() => callback(0)), + ); + vi.stubGlobal("cancelAnimationFrame", clearImmediate); vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); await disposeHighlighter(); pool = new WorkerPoolManager( @@ -130,10 +116,6 @@ afterEach(async () => { pool?.terminate(); await Promise.all(terminationPromises); await disposeHighlighter(); - for (const handle of pendingAnimationFrames) { - clearImmediate(handle); - } - pendingAnimationFrames.clear(); vi.unstubAllGlobals(); }); From 5b799db50314d74a3de9c3701981d3c8dd698f05 Mon Sep 17 00:00:00 2001 From: JustMarkDev Date: Wed, 9 Sep 2026 02:23:12 +0200 Subject: [PATCH 11/11] refactor: restore sticky naming for remembered access mode Match model selection's sticky vocabulary for the per-project remembered runtime mode. Keep a silent read of the brief lastUsed persistence keys so intermediate client state still hydrates. Co-authored-by: Cursor --- .../threads/new-task-flow-provider.tsx | 12 ++-- .../src/state/use-composer-drafts.test.ts | 30 +++++----- apps/mobile/src/state/use-composer-drafts.ts | 55 +++++++++---------- .../src/state/use-thread-composer-state.ts | 4 +- .../web/src/components/ChatView.logic.test.ts | 4 +- apps/web/src/components/ChatView.logic.ts | 6 +- apps/web/src/components/ChatView.tsx | 12 ++-- .../settings/ProjectDefaultsSettings.tsx | 2 +- apps/web/src/composerDraftStore.test.ts | 40 +++++++------- apps/web/src/composerDraftStore.ts | 45 ++++++++------- apps/web/src/hooks/useHandleNewThread.test.ts | 22 ++++---- apps/web/src/hooks/useHandleNewThread.ts | 20 +++---- packages/contracts/src/settings.ts | 2 +- packages/shared/src/runtimeMode.test.ts | 14 ++--- packages/shared/src/runtimeMode.ts | 6 +- 15 files changed, 135 insertions(+), 139 deletions(-) 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 1c67e94e63b2..adb6192d2fd1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -54,11 +54,11 @@ import { scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, setStickyComposerModelSelection, - setLastUsedComposerRuntimeMode, + setStickyComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, useStickyComposerModelSelection, - useLastUsedComposerRuntimeMode, + useStickyComposerRuntimeMode, } from "../../state/use-composer-drafts"; import { resolveNewThreadRuntimeMode } from "@t3tools/shared/runtimeMode"; import { @@ -474,15 +474,15 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedEnvironmentServerConfig, storedStickyModelSelection, ); - // Last-used access mode is keyed by logical project identity (same as web), + // 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 lastUsedRuntimeMode = useLastUsedComposerRuntimeMode(selectedLogicalProjectKey); + const stickyRuntimeMode = useStickyComposerRuntimeMode(selectedLogicalProjectKey); const runtimeMode = resolveNewThreadRuntimeMode({ draftRuntimeMode: selectedProjectDraft.runtimeMode, - lastUsedRuntimeMode, + stickyRuntimeMode, configuredRuntimeMode: selectedEnvironmentServerConfig?.settings.defaultRuntimeMode, }); const modelOptions = useMemo( @@ -889,7 +889,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); } if (selectedLogicalProjectKey) { - setLastUsedComposerRuntimeMode(selectedLogicalProjectKey, value); + setStickyComposerRuntimeMode(selectedLogicalProjectKey, value); } }, [selectedLogicalProjectKey, selectedProjectDraftKey], diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 1a8b22d964ac..527f5a8c8933 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -178,9 +178,9 @@ import { setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, setStickyComposerModelSelection, - setLastUsedComposerRuntimeMode, + setStickyComposerRuntimeMode, stickyComposerModelSelectionAtom, - lastUsedComposerRuntimeModeByProjectKeyAtom, + stickyComposerRuntimeModeByProjectKeyAtom, undoComposerDraftMerge, undoComposerDraftMergeState, } from "./use-composer-drafts"; @@ -204,7 +204,7 @@ afterEach(() => { appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); - appAtomRegistry.set(lastUsedComposerRuntimeModeByProjectKeyAtom, {}); + appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, {}); appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); composerAttachmentCleanupMocks.remove.mockClear(); composerAttachmentCleanupMocks.releaseUploads.mockReset(); @@ -213,44 +213,44 @@ afterEach(() => { incomingShareStorageMocks.load.mockResolvedValue([]); }); -describe("mobile last-used runtime mode", () => { - it("round-trips last-used runtime modes per project", () => { +describe("mobile sticky runtime mode", () => { + it("round-trips sticky runtime modes per project", () => { expect( decodePersistedComposerState({ schemaVersion: 1, drafts: {}, - lastUsedRuntimeModeByProjectKey: { + stickyRuntimeModeByProjectKey: { "env:project-a": "approval-required", "env:project-b": "auto-accept-edits", }, - }).lastUsedRuntimeModeByProjectKey, + }).stickyRuntimeModeByProjectKey, ).toEqual({ "env:project-a": "approval-required", "env:project-b": "auto-accept-edits", }); }); - it("reads legacy stickyRuntimeModeByProjectKey as last-used", () => { + it("reads an intermediate lastUsedRuntimeModeByProjectKey as sticky", () => { expect( decodePersistedComposerState({ schemaVersion: 1, drafts: {}, - stickyRuntimeModeByProjectKey: { + lastUsedRuntimeModeByProjectKey: { "env:project-a": "approval-required", }, - }).lastUsedRuntimeModeByProjectKey, + }).stickyRuntimeModeByProjectKey, ).toEqual({ "env:project-a": "approval-required", }); }); - it("stores last-used runtime mode updates in memory", () => { - setLastUsedComposerRuntimeMode("env:project-a", "approval-required"); - expect(appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom)).toEqual({ + it("stores sticky runtime mode updates in memory", () => { + setStickyComposerRuntimeMode("env:project-a", "approval-required"); + expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({ "env:project-a": "approval-required", }); - setLastUsedComposerRuntimeMode("env:project-a", null); - expect(appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom)).toEqual({}); + setStickyComposerRuntimeMode("env:project-a", null); + expect(appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)).toEqual({}); }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 1e78f5f05bb3..7096b32f0ea7 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -127,9 +127,8 @@ const PersistedComposerDraftsSchema = Schema.Struct({ schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), drafts: Schema.Record(Schema.String, ComposerDraftSchema), stickyModelSelection: Schema.optional(ModelSelectionSchema), - lastUsedRuntimeModeByProjectKey: Schema.optional(Schema.Record(Schema.String, RuntimeModeSchema)), - /** @deprecated Renamed to lastUsedRuntimeModeByProjectKey. */ 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( @@ -161,9 +160,9 @@ export const stickyComposerModelSelectionAtom = Atom.make Atom.withLabel("mobile:sticky-composer-model-selection"), ); -export const lastUsedComposerRuntimeModeByProjectKeyAtom = Atom.make< +export const stickyComposerRuntimeModeByProjectKeyAtom = Atom.make< Partial> ->({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:last-used-composer-runtime-mode-by-project")); +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:sticky-composer-runtime-mode-by-project")); interface SignedOutDrafts { readonly drafts: Record; @@ -280,7 +279,7 @@ export function migrateLegacyNewTaskDraft( export function decodePersistedComposerState(value: unknown): { readonly drafts: Record; readonly stickyModelSelection: ModelSelection | null; - readonly lastUsedRuntimeModeByProjectKey: Partial>; + readonly stickyRuntimeModeByProjectKey: Partial>; readonly cloudDrafts: ComposerCloudDraftState; } { const parsed = decodePersistedComposerDraftsDocument(value); @@ -315,8 +314,8 @@ export function decodePersistedComposerState(value: unknown): { .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0), ), stickyModelSelection: parsed.stickyModelSelection ?? null, - lastUsedRuntimeModeByProjectKey: normalizeLastUsedRuntimeModeByProjectKey( - parsed.lastUsedRuntimeModeByProjectKey ?? parsed.stickyRuntimeModeByProjectKey, + stickyRuntimeModeByProjectKey: normalizeStickyRuntimeModeByProjectKey( + parsed.stickyRuntimeModeByProjectKey ?? parsed.lastUsedRuntimeModeByProjectKey, ), cloudDrafts: { accountId: parsed.cloudAccountId ?? null, @@ -339,7 +338,7 @@ export function decodePersistedComposerState(value: unknown): { }; } -function normalizeLastUsedRuntimeModeByProjectKey( +function normalizeStickyRuntimeModeByProjectKey( value: Partial> | undefined, ): Partial> { if (!value) { @@ -373,7 +372,7 @@ async function loadPersistedComposerState(): Promise< return { drafts: {}, stickyModelSelection: null, - lastUsedRuntimeModeByProjectKey: {}, + stickyRuntimeModeByProjectKey: {}, cloudDrafts: { accountId: null, signedOut: {} }, }; } @@ -394,8 +393,8 @@ async function loadPersistedComposerState(): Promise< async function writePersistedComposerState( drafts: Record, stickyModelSelection: ModelSelection | null, - lastUsedRuntimeModeByProjectKey: Partial> = appAtomRegistry.get( - lastUsedComposerRuntimeModeByProjectKeyAtom, + stickyRuntimeModeByProjectKey: Partial> = appAtomRegistry.get( + stickyComposerRuntimeModeByProjectKeyAtom, ), cloudDrafts = appAtomRegistry.get(composerCloudDraftsAtom), ): Promise { @@ -406,8 +405,8 @@ async function writePersistedComposerState( const nonEmptyDrafts = Object.fromEntries( Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), ); - const lastUsedRuntimeModes = Object.fromEntries( - Object.entries(lastUsedRuntimeModeByProjectKey).filter( + const stickyRuntimeModes = Object.fromEntries( + Object.entries(stickyRuntimeModeByProjectKey).filter( (entry): entry is [string, RuntimeMode] => entry[1] !== undefined, ), ); @@ -415,8 +414,8 @@ async function writePersistedComposerState( schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, ...(stickyModelSelection ? { stickyModelSelection } : {}), - ...(Object.keys(lastUsedRuntimeModes).length > 0 - ? { lastUsedRuntimeModeByProjectKey: lastUsedRuntimeModes } + ...(Object.keys(stickyRuntimeModes).length > 0 + ? { stickyRuntimeModeByProjectKey: stickyRuntimeModes } : {}), ...(cloudDrafts.accountId ? { cloudAccountId: cloudDrafts.accountId } : {}), ...(Object.keys(cloudDrafts.signedOut).length > 0 @@ -669,7 +668,7 @@ function schedulePersistComposerState(): void { await writePersistedComposerState( appAtomRegistry.get(composerDraftsAtom), appAtomRegistry.get(stickyComposerModelSelectionAtom), - appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom), + appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom), ); persistRetryNeeded = false; } catch (error) { @@ -702,10 +701,10 @@ export function ensureComposerDraftsLoaded(): void { ) { appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection); } - if (Object.keys(persisted.lastUsedRuntimeModeByProjectKey).length > 0) { - const current = appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom); - appAtomRegistry.set(lastUsedComposerRuntimeModeByProjectKeyAtom, { - ...persisted.lastUsedRuntimeModeByProjectKey, + if (Object.keys(persisted.stickyRuntimeModeByProjectKey).length > 0) { + const current = appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom); + appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, { + ...persisted.stickyRuntimeModeByProjectKey, ...current, }); } @@ -940,15 +939,15 @@ export function setStickyComposerModelSelection(modelSelection: ModelSelection): schedulePersistComposerState(); } -export function getLastUsedComposerRuntimeMode(projectKey: string): RuntimeMode | null { +export function getStickyComposerRuntimeMode(projectKey: string): RuntimeMode | null { const key = projectKey.trim(); if (key.length === 0) { return null; } - return appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom)[key] ?? null; + return appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom)[key] ?? null; } -export function setLastUsedComposerRuntimeMode( +export function setStickyComposerRuntimeMode( projectKey: string, runtimeMode: RuntimeMode | null | undefined, ): void { @@ -956,7 +955,7 @@ export function setLastUsedComposerRuntimeMode( if (key.length === 0) { return; } - const current = appAtomRegistry.get(lastUsedComposerRuntimeModeByProjectKeyAtom); + const current = appAtomRegistry.get(stickyComposerRuntimeModeByProjectKeyAtom); const nextMode = runtimeMode ?? null; if ((current[key] ?? null) === nextMode) { return; @@ -967,7 +966,7 @@ export function setLastUsedComposerRuntimeMode( } else { next[key] = nextMode; } - appAtomRegistry.set(lastUsedComposerRuntimeModeByProjectKeyAtom, next); + appAtomRegistry.set(stickyComposerRuntimeModeByProjectKeyAtom, next); schedulePersistComposerState(); } @@ -1571,13 +1570,13 @@ export function useStickyComposerModelSelection(): ModelSelection | null { return selection; } -export function useLastUsedComposerRuntimeMode(projectKey: string | null): RuntimeMode | null { - const lastUsedByProject = useAtomValue(lastUsedComposerRuntimeModeByProjectKeyAtom); +export function useStickyComposerRuntimeMode(projectKey: string | null): RuntimeMode | null { + const stickyByProject = useAtomValue(stickyComposerRuntimeModeByProjectKeyAtom); useEffect(() => { ensureComposerDraftsLoaded(); }, []); if (!projectKey) { return null; } - return lastUsedByProject[projectKey] ?? 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 1390d414a705..7955e8df1ae8 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -49,7 +49,7 @@ import { removeComposerDraftAttachment, scheduleUnusedComposerAttachmentCleanup, setComposerDraftText, - setLastUsedComposerRuntimeMode, + setStickyComposerRuntimeMode, updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; @@ -598,7 +598,7 @@ export function useThreadComposerState() { if (!selectedThreadProject) { return; } - setLastUsedComposerRuntimeMode( + setStickyComposerRuntimeMode( deriveLogicalProjectKeyFromSettings(selectedThreadProject, projectGroupingSettings), value, ); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b7d229c481c5..3f5439c47cc9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -264,7 +264,7 @@ describe("proactive panels", () => { expect( resolveNewPullRequestDraftRuntimeMode({ viewedThreadRuntimeMode: "approval-required", - lastUsedRuntimeMode: null, + stickyRuntimeMode: null, configuredRuntimeMode: "full-access", }), ).toBe("approval-required"); @@ -272,7 +272,7 @@ describe("proactive panels", () => { resolveNewPullRequestDraftRuntimeMode({ composerRuntimeMode: "auto-accept-edits", viewedThreadRuntimeMode: "approval-required", - lastUsedRuntimeMode: "full-access", + stickyRuntimeMode: "full-access", configuredRuntimeMode: "full-access", }), ).toBe("auto-accept-edits"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 606439d84c45..ca37ae36a0d9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -633,18 +633,18 @@ export interface PullRequestDialogState { /** * Access mode for a freshly minted pull-request draft opened from ChatView. - * Composer overrides win over the viewed thread/session, then last-used and + * 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 lastUsedRuntimeMode?: RuntimeMode | null; + readonly stickyRuntimeMode?: RuntimeMode | null; readonly configuredRuntimeMode?: RuntimeMode | null; }): RuntimeMode { return resolveNewThreadRuntimeMode({ carryRuntimeMode: sources.composerRuntimeMode ?? sources.viewedThreadRuntimeMode ?? null, - lastUsedRuntimeMode: sources.lastUsedRuntimeMode ?? null, + stickyRuntimeMode: sources.stickyRuntimeMode ?? null, configuredRuntimeMode: sources.configuredRuntimeMode ?? null, }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b3b52953ccc8..9d37f2d57ecc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1501,8 +1501,8 @@ export default function ChatView(props: ChatViewProps) { const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); - const setLastUsedRuntimeMode = useComposerDraftStore((store) => store.setLastUsedRuntimeMode); - const getLastUsedRuntimeMode = useComposerDraftStore((store) => store.getLastUsedRuntimeMode); + const setStickyRuntimeMode = useComposerDraftStore((store) => store.setStickyRuntimeMode); + const getStickyRuntimeMode = useComposerDraftStore((store) => store.getStickyRuntimeMode); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const citationLocation = useLocation({ @@ -2271,7 +2271,7 @@ export default function ChatView(props: ChatViewProps) { const resolvedRuntimeMode = resolveNewPullRequestDraftRuntimeMode({ composerRuntimeMode, viewedThreadRuntimeMode: activeThread?.runtimeMode ?? null, - lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), + stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), configuredRuntimeMode: settings.defaultRuntimeMode ?? null, }); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { @@ -2294,7 +2294,7 @@ export default function ChatView(props: ChatViewProps) { draftId, getDraftSession, getDraftSessionByLogicalProjectKey, - getLastUsedRuntimeMode, + getStickyRuntimeMode, isServerThread, navigate, projectGroupingSettings, @@ -4060,7 +4060,7 @@ export default function ChatView(props: ChatViewProps) { setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } if (activeLogicalProjectKey) { - setLastUsedRuntimeMode(activeLogicalProjectKey, mode); + setStickyRuntimeMode(activeLogicalProjectKey, mode); } scheduleComposerFocus(); }, @@ -4072,7 +4072,7 @@ export default function ChatView(props: ChatViewProps) { composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, - setLastUsedRuntimeMode, + setStickyRuntimeMode, ], ); diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 7b0d47ea9e56..85753c2da306 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -328,7 +328,7 @@ export function ProjectDefaultsSettings({ { logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, - lastUsedRuntimeModeByLogicalProjectKey: {}, + stickyRuntimeModeByLogicalProjectKey: {}, }); }); @@ -788,7 +788,7 @@ describe("composerDraftStore terminal contexts", () => { logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, - lastUsedRuntimeModeByLogicalProjectKey: {}, + stickyRuntimeModeByLogicalProjectKey: {}, }); }); @@ -2549,36 +2549,36 @@ describe("composerDraftStore provider-scoped option updates", () => { }); }); -describe("composerDraftStore last-used runtime mode", () => { +describe("composerDraftStore sticky runtime mode", () => { beforeEach(() => { resetComposerDraftStore(); }); - it("stores a last-used runtime mode per logical project", () => { + it("stores a sticky runtime mode per logical project", () => { const store = useComposerDraftStore.getState(); - store.setLastUsedRuntimeMode("project-a", "approval-required"); - store.setLastUsedRuntimeMode("project-b", "auto-accept-edits"); + store.setStickyRuntimeMode("project-a", "approval-required"); + store.setStickyRuntimeMode("project-b", "auto-accept-edits"); - expect(store.getLastUsedRuntimeMode("project-a")).toBe("approval-required"); - expect(store.getLastUsedRuntimeMode("project-b")).toBe("auto-accept-edits"); - expect(useComposerDraftStore.getState().lastUsedRuntimeModeByLogicalProjectKey).toEqual({ + 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 last-used runtime mode for a project", () => { + it("clears sticky runtime mode for a project", () => { const store = useComposerDraftStore.getState(); - store.setLastUsedRuntimeMode("project-a", "approval-required"); - store.setLastUsedRuntimeMode("project-a", null); + store.setStickyRuntimeMode("project-a", "approval-required"); + store.setStickyRuntimeMode("project-a", null); - expect(store.getLastUsedRuntimeMode("project-a")).toBeNull(); - expect(useComposerDraftStore.getState().lastUsedRuntimeModeByLogicalProjectKey).toEqual({}); + expect(store.getStickyRuntimeMode("project-a")).toBeNull(); + expect(useComposerDraftStore.getState().stickyRuntimeModeByLogicalProjectKey).toEqual({}); }); - it("migrates legacy stickyRuntimeModeByLogicalProjectKey on hydrate", async () => { + it("reads an intermediate lastUsedRuntimeModeByLogicalProjectKey on hydrate", async () => { vi.useFakeTimers(); try { await useComposerDraftStore.persist.clearStorage(); @@ -2590,7 +2590,7 @@ describe("composerDraftStore last-used runtime mode", () => { draftsByThreadKey: {}, draftThreadsByThreadKey: {}, logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyRuntimeModeByLogicalProjectKey: { + lastUsedRuntimeModeByLogicalProjectKey: { "project-legacy": "auto", }, }, @@ -2598,12 +2598,10 @@ describe("composerDraftStore last-used runtime mode", () => { await vi.advanceTimersByTimeAsync(300); await useComposerDraftStore.persist.rehydrate(); - expect(useComposerDraftStore.getState().lastUsedRuntimeModeByLogicalProjectKey).toEqual({ + expect(useComposerDraftStore.getState().stickyRuntimeModeByLogicalProjectKey).toEqual({ "project-legacy": "auto", }); - expect(useComposerDraftStore.getState().getLastUsedRuntimeMode("project-legacy")).toBe( - "auto", - ); + expect(useComposerDraftStore.getState().getStickyRuntimeMode("project-legacy")).toBe("auto"); } finally { vi.useRealTimers(); await useComposerDraftStore.persist.clearStorage(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0b916393c475..36d46c1a1bc7 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -307,8 +307,7 @@ type LegacyV2StoreFields = { draftThreadsByThreadKey?: Record | null; projectDraftThreadKeyByProjectKey?: Record | null; logicalProjectDraftThreadKeyByLogicalProjectKey?: Record | null; - /** @deprecated Renamed to lastUsedRuntimeModeByLogicalProjectKey. */ - stickyRuntimeModeByLogicalProjectKey?: Record | null; + lastUsedRuntimeModeByLogicalProjectKey?: Record | null; }; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & @@ -348,8 +347,8 @@ const PersistedComposerDraftStoreState = Schema.Struct({ Schema.Record(ProviderInstanceId, ModelSelection), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), - /** Last-used runtime/access mode keyed by logical project identity. */ - lastUsedRuntimeModeByLogicalProjectKey: Schema.optionalKey( + /** Sticky runtime/access mode keyed by logical project identity. */ + stickyRuntimeModeByLogicalProjectKey: Schema.optionalKey( Schema.Record(Schema.String, RuntimeMode), ), }); @@ -483,7 +482,7 @@ interface ComposerDraftStoreState { backgroundSubmissionThreadKeys: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderInstanceId | null; - lastUsedRuntimeModeByLogicalProjectKey: Partial>; + 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. */ @@ -565,8 +564,8 @@ interface ComposerDraftStoreState { finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; - getLastUsedRuntimeMode: (logicalProjectKey: string) => RuntimeMode | null; - setLastUsedRuntimeMode: ( + getStickyRuntimeMode: (logicalProjectKey: string) => RuntimeMode | null; + setStickyRuntimeMode: ( logicalProjectKey: string, runtimeMode: RuntimeMode | null | undefined, ) => void; @@ -742,7 +741,7 @@ function compactModelSelectionByProvider( return Object.fromEntries(entries) as DeepMutable>; } -function normalizeLastUsedRuntimeModeByLogicalProjectKey( +function normalizeStickyRuntimeModeByLogicalProjectKey( value: unknown, ): Record { if (!value || typeof value !== "object") { @@ -765,7 +764,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze()( backgroundSubmissionThreadKeys: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, - lastUsedRuntimeModeByLogicalProjectKey: {}, + stickyRuntimeModeByLogicalProjectKey: {}, getComposerDraft: (target) => getComposerDraftState(get(), target), getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); @@ -2980,31 +2979,31 @@ const composerDraftStore = create()( }; }); }, - getLastUsedRuntimeMode: (logicalProjectKey) => { + getStickyRuntimeMode: (logicalProjectKey) => { const key = logicalProjectDraftKey(logicalProjectKey); if (key.length === 0) { return null; } - return get().lastUsedRuntimeModeByLogicalProjectKey[key] ?? null; + return get().stickyRuntimeModeByLogicalProjectKey[key] ?? null; }, - setLastUsedRuntimeMode: (logicalProjectKey, runtimeMode) => { + setStickyRuntimeMode: (logicalProjectKey, runtimeMode) => { const key = logicalProjectDraftKey(logicalProjectKey); if (key.length === 0) { return; } const nextRuntimeMode = isRuntimeMode(runtimeMode) ? runtimeMode : null; set((state) => { - const current = state.lastUsedRuntimeModeByLogicalProjectKey[key] ?? null; + const current = state.stickyRuntimeModeByLogicalProjectKey[key] ?? null; if (current === nextRuntimeMode) { return state; } - const nextMap = { ...state.lastUsedRuntimeModeByLogicalProjectKey }; + const nextMap = { ...state.stickyRuntimeModeByLogicalProjectKey }; if (nextRuntimeMode === null) { delete nextMap[key]; } else { nextMap[key] = nextRuntimeMode; } - return { lastUsedRuntimeModeByLogicalProjectKey: nextMap }; + return { stickyRuntimeModeByLogicalProjectKey: nextMap }; }); }, applyStickyState: (threadRef) => { @@ -4073,8 +4072,8 @@ const composerDraftStore = create()( normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, - lastUsedRuntimeModeByLogicalProjectKey: - normalizedPersisted.lastUsedRuntimeModeByLogicalProjectKey ?? {}, + stickyRuntimeModeByLogicalProjectKey: + normalizedPersisted.stickyRuntimeModeByLogicalProjectKey ?? {}, }; }, }, diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 20bfa8926109..24db4ea10522 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -23,8 +23,8 @@ const testState = vi.hoisted(() => { getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), getDraftSession: vi.fn(() => null), getDraftThread: vi.fn(() => null), - getLastUsedRuntimeMode: vi.fn((): string | null => null), - setLastUsedRuntimeMode: vi.fn(), + getStickyRuntimeMode: vi.fn((): string | null => null), + setStickyRuntimeMode: vi.fn(), applyStickyState: vi.fn(), setDraftThreadContext: vi.fn(), setLogicalProjectDraftThreadId: vi.fn(), @@ -45,13 +45,13 @@ const testState = vi.hoisted(() => { router.navigate.mockClear(); draftStore.getComposerDraft.mockReset(); draftStore.getComposerDraft.mockImplementation(() => ({})); - draftStore.getLastUsedRuntimeMode.mockReset(); - draftStore.getLastUsedRuntimeMode.mockImplementation(() => null); + draftStore.getStickyRuntimeMode.mockReset(); + draftStore.getStickyRuntimeMode.mockImplementation(() => null); draftStore.setLogicalProjectDraftThreadId.mockClear(); draftStore.setRuntimeMode.mockClear(); draftStore.setInteractionMode.mockClear(); draftStore.setDraftThreadContext.mockClear(); - draftStore.setLastUsedRuntimeMode.mockClear(); + draftStore.setStickyRuntimeMode.mockClear(); projectFileRead = new Promise((resolve) => { completeProjectFileRead = resolve; }); @@ -95,12 +95,12 @@ vi.mock("@t3tools/shared/runtimeMode", () => ({ resolveNewThreadRuntimeMode: (sources: { readonly draftRuntimeMode?: string | null; readonly carryRuntimeMode?: string | null; - readonly lastUsedRuntimeMode?: string | null; + readonly stickyRuntimeMode?: string | null; readonly configuredRuntimeMode?: string | null; }) => sources.draftRuntimeMode ?? sources.carryRuntimeMode ?? - sources.lastUsedRuntimeMode ?? + sources.stickyRuntimeMode ?? sources.configuredRuntimeMode ?? "full-access", })); @@ -216,7 +216,7 @@ describe("useNewThreadHandler", () => { "draft-existing", expect.objectContaining({ runtimeMode: "full-access" }), ); - // No composer pick yet — do not seed last-used into the composer draft. + // No composer pick yet — do not seed sticky into the composer draft. expect(testState.draftStore.setRuntimeMode).not.toHaveBeenCalled(); expect(testState.router.navigate).toHaveBeenCalled(); }); @@ -231,7 +231,7 @@ describe("useNewThreadHandler", () => { testState.draftStore.getComposerDraft.mockImplementation(() => ({ runtimeMode: "approval-required", })); - testState.draftStore.getLastUsedRuntimeMode.mockImplementation(() => "auto-accept-edits"); + testState.draftStore.getStickyRuntimeMode.mockImplementation(() => "auto-accept-edits"); const openThread = useNewThreadHandler(); const pendingOpen = openThread({ environmentId: "environment-ssh", @@ -251,7 +251,7 @@ describe("useNewThreadHandler", () => { ); }); - it("does not seed last-used from the machine default alone", async () => { + it("does not seed sticky from the machine default alone", async () => { testState.reset(null); const openThread = useNewThreadHandler(); const pendingOpen = openThread({ @@ -262,6 +262,6 @@ describe("useNewThreadHandler", () => { testState.completeProjectFileRead(null); await pendingOpen; - expect(testState.draftStore.setLastUsedRuntimeMode).not.toHaveBeenCalled(); + expect(testState.draftStore.setStickyRuntimeMode).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ed5025361cdf..4d53eba2ad39 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -87,8 +87,8 @@ export function useNewThreadHandler() { getDraftSessionByLogicalProjectKey, getDraftSession, getDraftThread, - getLastUsedRuntimeMode, - setLastUsedRuntimeMode, + getStickyRuntimeMode, + setStickyRuntimeMode, applyStickyState, setDraftThreadContext, setLogicalProjectDraftThreadId, @@ -101,9 +101,9 @@ export function useNewThreadHandler() { const currentRouteTarget = getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being // viewed only when that source belongs to the same logical project. - // Otherwise the project's last-used mode wins over the hardcoded + // 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 last-used. + // 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 = @@ -221,7 +221,7 @@ export function useNewThreadHandler() { : 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 last-used / Default access changes on + // would permanently shadow later sticky / Default access changes on // empty-draft resurrection. Prefer the composer's own mode when present. const emptyDraftComposerRuntimeMode = emptyStoredDraftThread != null @@ -230,14 +230,14 @@ export function useNewThreadHandler() { const resolvedRuntimeMode = resolveNewThreadRuntimeMode({ draftRuntimeMode: emptyDraftComposerRuntimeMode, carryRuntimeMode: sameProjectCarryRuntimeMode, - lastUsedRuntimeMode: getLastUsedRuntimeMode(logicalProjectKey), + stickyRuntimeMode: getStickyRuntimeMode(logicalProjectKey), configuredRuntimeMode: targetServerSettings.defaultRuntimeMode, }); - // Only promote carry into last-used. Seeding from the machine default would + // Only promote carry into sticky. Seeding from the machine default would // permanently shadow later Default access changes (including Auto). - // Explicit composer picks update last-used in ChatView. + // Explicit composer picks update sticky in ChatView. if (sameProjectCarryRuntimeMode != null) { - setLastUsedRuntimeMode(logicalProjectKey, resolvedRuntimeMode); + setStickyRuntimeMode(logicalProjectKey, resolvedRuntimeMode); } const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget ? currentRouteTarget.kind === "server" @@ -314,7 +314,7 @@ export function useNewThreadHandler() { } // 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 last-used / machine defaults into + // resurrect would turn ambient sticky / machine defaults into // sticky draft picks that shadow later Default access changes. if (emptyDraftComposerRuntimeMode != null) { setRuntimeMode(emptyStoredDraftThread.draftId, resolvedRuntimeMode); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 55f8ccf71bc5..30e0c2479524 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1013,7 +1013,7 @@ export const ServerSettings = Schema.Struct({ ), /** * Preferred access mode for new threads when nothing carries from the - * current view and the client has no last-used mode for the project. + * current view and the client has no sticky mode for the project. */ defaultRuntimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE)), diff --git a/packages/shared/src/runtimeMode.test.ts b/packages/shared/src/runtimeMode.test.ts index 0951f5f7c9a0..e317b22deece 100644 --- a/packages/shared/src/runtimeMode.test.ts +++ b/packages/shared/src/runtimeMode.test.ts @@ -3,37 +3,37 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveNewThreadRuntimeMode } from "./runtimeMode.ts"; describe("resolveNewThreadRuntimeMode", () => { - it("prefers an explicit draft pick over carry, last-used, and configured", () => { + it("prefers an explicit draft pick over carry, sticky, and configured", () => { expect( resolveNewThreadRuntimeMode({ draftRuntimeMode: "approval-required", carryRuntimeMode: "auto-accept-edits", - lastUsedRuntimeMode: "full-access", + stickyRuntimeMode: "full-access", configuredRuntimeMode: "auto", }), ).toBe("approval-required"); }); - it("prefers same-project carry over last-used and configured", () => { + it("prefers same-project carry over sticky and configured", () => { expect( resolveNewThreadRuntimeMode({ carryRuntimeMode: "auto-accept-edits", - lastUsedRuntimeMode: "approval-required", + stickyRuntimeMode: "approval-required", configuredRuntimeMode: "auto", }), ).toBe("auto-accept-edits"); }); - it("uses project last-used when nothing carries", () => { + it("uses project sticky when nothing carries", () => { expect( resolveNewThreadRuntimeMode({ - lastUsedRuntimeMode: "approval-required", + stickyRuntimeMode: "approval-required", configuredRuntimeMode: "auto", }), ).toBe("approval-required"); }); - it("uses the configured default when last-used is absent", () => { + it("uses the configured default when sticky is absent", () => { expect( resolveNewThreadRuntimeMode({ configuredRuntimeMode: "auto", diff --git a/packages/shared/src/runtimeMode.ts b/packages/shared/src/runtimeMode.ts index 5314155fc568..e4ecdaaa8c71 100644 --- a/packages/shared/src/runtimeMode.ts +++ b/packages/shared/src/runtimeMode.ts @@ -3,7 +3,7 @@ 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 last-used > configured + * explicit draft pick > same-project carry > project sticky > configured * machine/project default > hardcoded default. * * Interaction/plan mode stays out of this resolver on purpose. @@ -11,13 +11,13 @@ import { DEFAULT_RUNTIME_MODE, type RuntimeMode } from "@t3tools/contracts"; export function resolveNewThreadRuntimeMode(sources: { readonly draftRuntimeMode?: RuntimeMode | null; readonly carryRuntimeMode?: RuntimeMode | null; - readonly lastUsedRuntimeMode?: RuntimeMode | null; + readonly stickyRuntimeMode?: RuntimeMode | null; readonly configuredRuntimeMode?: RuntimeMode | null; }): RuntimeMode { return ( sources.draftRuntimeMode ?? sources.carryRuntimeMode ?? - sources.lastUsedRuntimeMode ?? + sources.stickyRuntimeMode ?? sources.configuredRuntimeMode ?? DEFAULT_RUNTIME_MODE );