From 1343d28b643718dff9978a8c22644b541fd55afd Mon Sep 17 00:00:00 2001 From: mask Date: Thu, 12 Mar 2026 21:35:33 -0500 Subject: [PATCH 1/7] Persist Codex composer preferences --- apps/web/src/appSettings.test.ts | 14 +++++++ apps/web/src/appSettings.ts | 24 ++++++++++- apps/web/src/codexReasoningEffort.ts | 8 ++++ apps/web/src/components/ChatView.tsx | 33 +++++++++++---- .../src/components/chat/CodexTraitsPicker.tsx | 16 +++----- .../chat/CompactComposerControlsMenu.tsx | 15 ++----- apps/web/src/composerDraftStore.test.ts | 39 +++++++++++++++++- apps/web/src/composerDraftStore.ts | 41 +++++++++---------- 8 files changed, 137 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/codexReasoningEffort.ts diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaac..6801ca6f0 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_CODEX_FAST_MODE, + DEFAULT_CODEX_REASONING_EFFORT, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, normalizeCustomModelSlugs, @@ -64,3 +66,15 @@ describe("timestamp format defaults", () => { expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); }); }); + +describe("reasoning defaults", () => { + it("defaults Codex reasoning to the built-in high level", () => { + expect(DEFAULT_CODEX_REASONING_EFFORT).toBe("high"); + }); +}); + +describe("fast mode defaults", () => { + it("defaults Codex fast mode to off", () => { + expect(DEFAULT_CODEX_FAST_MODE).toBe(false); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..721e38365 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,16 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; -import { type ProviderKind } from "@t3tools/contracts"; -import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { + CODEX_REASONING_EFFORT_OPTIONS, + type CodexReasoningEffort, + type ProviderKind, +} from "@t3tools/contracts"; +import { + getDefaultModel, + getDefaultReasoningEffort, + getModelOptions, + normalizeModelSlug, +} from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; @@ -10,6 +19,9 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const DEFAULT_CODEX_REASONING_EFFORT: CodexReasoningEffort = + getDefaultReasoningEffort("codex"); +export const DEFAULT_CODEX_FAST_MODE = false; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), }; @@ -31,6 +43,14 @@ const AppSettingsSchema = Schema.Struct({ timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), ), + // Kept under the existing storage key so local settings survive the move from + // an explicit settings control to composer-driven last-used persistence. + defaultCodexReasoningEffort: Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS).pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_CODEX_REASONING_EFFORT)), + ), + lastUsedCodexFastMode: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_CODEX_FAST_MODE)), + ), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), diff --git a/apps/web/src/codexReasoningEffort.ts b/apps/web/src/codexReasoningEffort.ts new file mode 100644 index 000000000..57d63d728 --- /dev/null +++ b/apps/web/src/codexReasoningEffort.ts @@ -0,0 +1,8 @@ +import { type CodexReasoningEffort } from "@t3tools/contracts"; + +export const CODEX_REASONING_EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..a26ee5c1e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -196,7 +196,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); - const { settings } = useAppSettings(); + const { settings, updateSettings } = useAppSettings(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ @@ -513,9 +513,16 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const defaultCodexReasoningEffort = settings.defaultCodexReasoningEffort; + const selectedEffort = + composerDraft.effort ?? + (selectedProvider === "codex" + ? defaultCodexReasoningEffort + : getDefaultReasoningEffort(selectedProvider)); const selectedCodexFastModeEnabled = - selectedProvider === "codex" ? composerDraft.codexFastMode : false; + selectedProvider === "codex" + ? (composerDraft.codexFastMode ?? settings.lastUsedCodexFastMode) + : false; const selectedModelOptionsForDispatch = useMemo(() => { if (selectedProvider !== "codex") { return undefined; @@ -2895,17 +2902,27 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onEffortSelect = useCallback( (effort: CodexReasoningEffort) => { - setComposerDraftEffort(threadId, effort); + updateSettings({ + defaultCodexReasoningEffort: effort, + }); + // Composer selections now define the global last-used preference, so the + // thread should fall back to that shared setting instead of pinning an override. + setComposerDraftEffort(threadId, null); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftEffort, threadId], + [scheduleComposerFocus, setComposerDraftEffort, threadId, updateSettings], ); const onCodexFastModeChange = useCallback( (enabled: boolean) => { - setComposerDraftCodexFastMode(threadId, enabled); + updateSettings({ + lastUsedCodexFastMode: enabled, + }); + // Keep the thread following the global last-used setting after the user + // changes it from the composer. + setComposerDraftCodexFastMode(threadId, null); scheduleComposerFocus(); }, - [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], + [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId, updateSettings], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { @@ -3503,6 +3520,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {isComposerFooterCompact ? ( ; onEffortChange: (effort: CodexReasoningEffort) => void; onFastModeChange: (enabled: boolean) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; const triggerLabel = [ - reasoningLabelByOption[props.effort], + CODEX_REASONING_EFFORT_LABELS[props.effort], ...(props.fastModeEnabled ? ["Fast"] : []), ] .filter(Boolean) @@ -68,8 +62,8 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { > {props.options.map((effort) => ( - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} + {CODEX_REASONING_EFFORT_LABELS[effort]} + {effort === props.defaultEffort ? " (default)" : ""} ))} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 0af50ff01..20c325633 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -4,9 +4,9 @@ import { RuntimeMode, ProviderInteractionMode, } from "@t3tools/contracts"; -import { getDefaultReasoningEffort } from "@t3tools/shared/model"; import { memo } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { CODEX_REASONING_EFFORT_LABELS } from "../../codexReasoningEffort"; import { Button } from "../ui/button"; import { Menu, @@ -21,6 +21,7 @@ import { export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; + defaultEffort: CodexReasoningEffort; interactionMode: ProviderInteractionMode; planSidebarOpen: boolean; runtimeMode: RuntimeMode; @@ -34,14 +35,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls onTogglePlanSidebar: () => void; onToggleRuntimeMode: () => void; }) { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; - return ( {props.reasoningOptions.map((effort) => ( - {reasoningLabelByOption[effort]} - {effort === defaultReasoningEffort ? " (default)" : ""} + {CODEX_REASONING_EFFORT_LABELS[effort]} + {effort === props.defaultEffort ? " (default)" : ""} ))} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 927a16060..294daf1f7 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -355,11 +355,19 @@ describe("composerDraftStore codex fast mode", () => { expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true); }); - it("clears codex fast mode when reset to the default", () => { + it("stores explicit fast mode off in the draft", () => { const store = useComposerDraftStore.getState(); store.setCodexFastMode(threadId, true); store.setCodexFastMode(threadId, false); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(false); + }); + + it("clears codex fast mode when reset to follow the global preference", () => { + const store = useComposerDraftStore.getState(); + store.setCodexFastMode(threadId, true); + store.setCodexFastMode(threadId, null); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); }); }); @@ -458,6 +466,35 @@ describe("composerDraftStore runtime and interaction settings", () => { }); }); +describe("composerDraftStore effort settings", () => { + const threadId = ThreadId.makeUnsafe("thread-effort"); + + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + }); + + it("stores explicit reasoning levels including high", () => { + const store = useComposerDraftStore.getState(); + + store.setEffort(threadId, "high"); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.effort).toBe("high"); + }); + + it("removes settings-only drafts when effort is cleared", () => { + const store = useComposerDraftStore.getState(); + + store.setEffort(threadId, "medium"); + store.setEffort(threadId, null); + + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // createDebouncedStorage // --------------------------------------------------------------------------- diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2af920527..abbecba7d 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,5 +1,4 @@ import { - DEFAULT_REASONING_EFFORT_BY_PROVIDER, ProjectId, REASONING_EFFORT_OPTIONS_BY_PROVIDER, ThreadId, @@ -109,7 +108,7 @@ interface ComposerThreadDraftState { runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; effort: CodexReasoningEffort | null; - codexFastMode: boolean; + codexFastMode: boolean | null; } export interface DraftThreadState { @@ -203,7 +202,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ runtimeMode: null, interactionMode: null, effort: null, - codexFastMode: false, + codexFastMode: null, }) as ComposerThreadDraftState; const REASONING_EFFORT_VALUES = new Set( @@ -221,7 +220,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { runtimeMode: null, interactionMode: null, effort: null, - codexFastMode: false, + codexFastMode: null, }; } @@ -241,7 +240,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === null ); } @@ -425,8 +424,13 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ? (effortCandidate as CodexReasoningEffort) : null; const codexFastMode = - draftCandidate.codexFastMode === true || - (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast"); + draftCandidate.codexFastMode === true + ? true + : draftCandidate.codexFastMode === false + ? false + : typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast" + ? true + : null; if ( prompt.length === 0 && attachments.length === 0 && @@ -435,7 +439,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer !runtimeMode && !interactionMode && !effort && - !codexFastMode + codexFastMode === null ) { continue; } @@ -447,7 +451,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer ...(runtimeMode ? { runtimeMode } : {}), ...(interactionMode ? { interactionMode } : {}), ...(effort ? { effort } : {}), - ...(codexFastMode ? { codexFastMode } : {}), + ...(codexFastMode !== null ? { codexFastMode } : {}), }; } return { @@ -553,7 +557,7 @@ function toHydratedThreadDraft( runtimeMode: persistedDraft.runtimeMode ?? null, interactionMode: persistedDraft.interactionMode ?? null, effort: persistedDraft.effort ?? null, - codexFastMode: persistedDraft.codexFastMode === true, + codexFastMode: persistedDraft.codexFastMode ?? null, }; } @@ -931,12 +935,7 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const nextEffort = - effort && - REASONING_EFFORT_VALUES.has(effort) && - effort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex - ? effort - : null; + const nextEffort = effort && REASONING_EFFORT_VALUES.has(effort) ? effort : null; set((state) => { const existing = state.draftsByThreadId[threadId]; if (!existing && nextEffort === null) { @@ -963,10 +962,10 @@ export const useComposerDraftStore = create()( if (threadId.length === 0) { return; } - const nextCodexFastMode = enabled === true; + const nextCodexFastMode = enabled === true ? true : enabled === false ? false : null; set((state) => { const existing = state.draftsByThreadId[threadId]; - if (!existing && nextCodexFastMode === false) { + if (!existing && nextCodexFastMode === null) { return state; } const base = existing ?? createEmptyThreadDraft(); @@ -1223,7 +1222,7 @@ export const useComposerDraftStore = create()( draft.runtimeMode === null && draft.interactionMode === null && draft.effort === null && - draft.codexFastMode === false + draft.codexFastMode === null ) { continue; } @@ -1246,8 +1245,8 @@ export const useComposerDraftStore = create()( if (draft.effort) { persistedDraft.effort = draft.effort; } - if (draft.codexFastMode) { - persistedDraft.codexFastMode = true; + if (draft.codexFastMode !== null) { + persistedDraft.codexFastMode = draft.codexFastMode; } persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; } From cb017dd61deb316b7eade4bc17d9ca5d0a353a74 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 20:58:07 -0700 Subject: [PATCH 2/7] move --- apps/web/src/codexReasoningEffort.ts | 8 -------- apps/web/src/components/chat/CodexTraitsPicker.tsx | 2 +- .../src/components/chat/CompactComposerControlsMenu.tsx | 2 +- packages/contracts/src/model.ts | 7 +++++++ 4 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 apps/web/src/codexReasoningEffort.ts diff --git a/apps/web/src/codexReasoningEffort.ts b/apps/web/src/codexReasoningEffort.ts deleted file mode 100644 index 57d63d728..000000000 --- a/apps/web/src/codexReasoningEffort.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { type CodexReasoningEffort } from "@t3tools/contracts"; - -export const CODEX_REASONING_EFFORT_LABELS: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", -}; diff --git a/apps/web/src/components/chat/CodexTraitsPicker.tsx b/apps/web/src/components/chat/CodexTraitsPicker.tsx index cfb351354..3b853f88d 100644 --- a/apps/web/src/components/chat/CodexTraitsPicker.tsx +++ b/apps/web/src/components/chat/CodexTraitsPicker.tsx @@ -1,7 +1,7 @@ import { type CodexReasoningEffort } from "@t3tools/contracts"; import { memo, useState } from "react"; import { ChevronDownIcon } from "lucide-react"; -import { CODEX_REASONING_EFFORT_LABELS } from "../../codexReasoningEffort"; +import { CODEX_REASONING_EFFORT_LABELS } from "@t3tools/contracts"; import { Button } from "../ui/button"; import { Menu, diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 20c325633..33bb4cd5d 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -6,7 +6,7 @@ import { } from "@t3tools/contracts"; import { memo } from "react"; import { EllipsisIcon, ListTodoIcon } from "lucide-react"; -import { CODEX_REASONING_EFFORT_LABELS } from "../../codexReasoningEffort"; +import { CODEX_REASONING_EFFORT_LABELS } from "@t3tools/contracts"; import { Button } from "../ui/button"; import { Menu, diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..254bca597 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -4,6 +4,13 @@ import { ProviderKind } from "./orchestration"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; +export const CODEX_REASONING_EFFORT_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; + export const CodexModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), From 9784a375ae27d133fb0011ba03f55cf0d9e99a81 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 08:42:27 -0500 Subject: [PATCH 3/7] Preserve composer draft settings on thread promotion --- apps/web/src/components/ChatView.browser.tsx | 9 +++-- apps/web/src/composerDraftStore.test.ts | 13 +++++++ apps/web/src/composerDraftStore.ts | 40 +++++++++++++++++--- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index faecc7f51..0a4a43fa4 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -20,7 +20,7 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; import { isMacPlatform } from "../lib/utils"; import { getRouter } from "../router"; import { useStore } from "../store"; @@ -1067,8 +1067,9 @@ describe("ChatView timeline estimator parity (full app)", () => { const { syncServerReadModel } = useStore.getState(); syncServerReadModel(addThreadToSnapshot(fixture.snapshot, newThreadId)); - // Clear the draft now that the server thread exists (mirrors EventRouter behavior). - useComposerDraftStore.getState().clearDraftThread(newThreadId); + // Clear promoted draft-thread metadata now that the server thread exists + // (mirrors EventRouter behavior without dropping composer draft settings). + clearPromotedDraftThreads(new Set([newThreadId])); // The route should still be on the new thread — not redirected away. await waitForURL( @@ -1186,7 +1187,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const { syncServerReadModel } = useStore.getState(); syncServerReadModel(addThreadToSnapshot(fixture.snapshot, promotedThreadId)); - useComposerDraftStore.getState().clearDraftThread(promotedThreadId); + clearPromotedDraftThreads(new Set([promotedThreadId])); const useMetaForMod = isMacPlatform(navigator.platform); window.dispatchEvent( diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 294daf1f7..2f139079d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2,6 +2,7 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + clearPromotedDraftThreads, type ComposerImageAttachment, createDebouncedStorage, useComposerDraftStore, @@ -266,6 +267,18 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); }); + it("preserves composer draft settings when a draft thread is promoted", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setEffort(threadId, "low"); + + clearPromotedDraftThreads(new Set([threadId])); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); + expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.effort).toBe("low"); + }); + it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectId, threadId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index abbecba7d..82f552d60 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1287,11 +1287,39 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS * to `/` caused by a gap where neither draft nor server thread exists. */ export function clearPromotedDraftThreads(serverThreadIds: ReadonlySet): void { - const store = useComposerDraftStore.getState(); - const draftThreadIds = Object.keys(store.draftThreadsByThreadId) as ThreadId[]; - for (const draftId of draftThreadIds) { - if (serverThreadIds.has(draftId)) { - store.clearDraftThread(draftId); + useComposerDraftStore.setState((state) => { + let nextDraftThreadsByThreadId = state.draftThreadsByThreadId; + let nextProjectDraftThreadIdByProjectId = state.projectDraftThreadIdByProjectId; + let changed = false; + + for (const draftId of Object.keys(state.draftThreadsByThreadId) as ThreadId[]) { + if (!serverThreadIds.has(draftId)) { + continue; + } + + if (!changed) { + nextDraftThreadsByThreadId = { ...state.draftThreadsByThreadId }; + nextProjectDraftThreadIdByProjectId = { ...state.projectDraftThreadIdByProjectId }; + changed = true; + } + + delete nextDraftThreadsByThreadId[draftId]; + for (const [projectId, mappedThreadId] of Object.entries( + nextProjectDraftThreadIdByProjectId, + )) { + if (mappedThreadId === draftId) { + delete nextProjectDraftThreadIdByProjectId[projectId as ProjectId]; + } + } } - } + + if (!changed) { + return state; + } + + return { + draftThreadsByThreadId: nextDraftThreadsByThreadId, + projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, + }; + }); } From 5284b1a348dee69d23ffab60c4c545d9c99bf64a Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 08:42:51 -0500 Subject: [PATCH 4/7] Pin Codex effort and fast mode per thread --- apps/web/src/appSettings.ts | 2 +- .../web/src/components/ChatView.logic.test.ts | 24 ++++++++ apps/web/src/components/ChatView.logic.ts | 22 ++++++- apps/web/src/components/ChatView.tsx | 60 ++++++++++++++----- 4 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/ChatView.logic.test.ts diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 721e38365..d6818256e 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -44,7 +44,7 @@ const AppSettingsSchema = Schema.Struct({ Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), ), // Kept under the existing storage key so local settings survive the move from - // an explicit settings control to composer-driven last-used persistence. + // an explicit default control to composer-driven last-used persistence. defaultCodexReasoningEffort: Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS).pipe( Schema.withConstructorDefault(() => Option.some(DEFAULT_CODEX_REASONING_EFFORT)), ), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts new file mode 100644 index 000000000..19b7f9131 --- /dev/null +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { resolveComposerReasoningEffort } from "./ChatView.logic"; + +describe("resolveComposerReasoningEffort", () => { + it("prefers the thread draft effort over the global codex fallback", () => { + expect( + resolveComposerReasoningEffort({ + composerDraftEffort: "high", + provider: "codex", + defaultCodexReasoningEffort: "low", + }), + ).toBe("high"); + }); + + it("uses the global last-used codex reasoning effort", () => { + expect( + resolveComposerReasoningEffort({ + composerDraftEffort: null, + provider: "codex", + defaultCodexReasoningEffort: "low", + }), + ).toBe("low"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 59e290431..8657c4138 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,9 +1,15 @@ -import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { + ProjectId, + type CodexReasoningEffort, + type ProviderKind, + type ThreadId, +} from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { getAppModelOptions } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; +import { getDefaultReasoningEffort } from "@t3tools/shared/model"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; @@ -123,3 +129,17 @@ export function getCustomModelOptionsByProvider(settings: { codex: getAppModelOptions("codex", settings.customCodexModels), }; } + +export function resolveComposerReasoningEffort(input: { + composerDraftEffort: CodexReasoningEffort | null; + provider: ProviderKind; + defaultCodexReasoningEffort: CodexReasoningEffort; +}): CodexReasoningEffort | null { + if (input.composerDraftEffort) { + return input.composerDraftEffort; + } + if (input.provider === "codex") { + return input.defaultCodexReasoningEffort; + } + return getDefaultReasoningEffort(input.provider); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a26ee5c1e..066f7b8cd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -154,6 +154,7 @@ import { LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, + resolveComposerReasoningEffort, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, @@ -513,16 +514,47 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; - const defaultCodexReasoningEffort = settings.defaultCodexReasoningEffort; - const selectedEffort = - composerDraft.effort ?? - (selectedProvider === "codex" - ? defaultCodexReasoningEffort - : getDefaultReasoningEffort(selectedProvider)); + const defaultReasoningEffort = getDefaultReasoningEffort(selectedProvider); + const selectedEffort = resolveComposerReasoningEffort({ + composerDraftEffort: composerDraft.effort, + provider: selectedProvider, + defaultCodexReasoningEffort: settings.defaultCodexReasoningEffort, + }); const selectedCodexFastModeEnabled = selectedProvider === "codex" ? (composerDraft.codexFastMode ?? settings.lastUsedCodexFastMode) : false; + + useEffect(() => { + if (selectedProvider !== "codex") { + return; + } + if (composerDraft.effort !== null) { + return; + } + setComposerDraftEffort(threadId, settings.defaultCodexReasoningEffort); + }, [ + composerDraft.effort, + selectedProvider, + setComposerDraftEffort, + settings.defaultCodexReasoningEffort, + threadId, + ]); + useEffect(() => { + if (selectedProvider !== "codex") { + return; + } + if (composerDraft.codexFastMode !== null) { + return; + } + setComposerDraftCodexFastMode(threadId, settings.lastUsedCodexFastMode); + }, [ + composerDraft.codexFastMode, + selectedProvider, + setComposerDraftCodexFastMode, + settings.lastUsedCodexFastMode, + threadId, + ]); const selectedModelOptionsForDispatch = useMemo(() => { if (selectedProvider !== "codex") { return undefined; @@ -2905,9 +2937,9 @@ export default function ChatView({ threadId }: ChatViewProps) { updateSettings({ defaultCodexReasoningEffort: effort, }); - // Composer selections now define the global last-used preference, so the - // thread should fall back to that shared setting instead of pinning an override. - setComposerDraftEffort(threadId, null); + // Persist the thread-level selection while also updating the global + // fallback used for new threads. + setComposerDraftEffort(threadId, effort); scheduleComposerFocus(); }, [scheduleComposerFocus, setComposerDraftEffort, threadId, updateSettings], @@ -2917,9 +2949,9 @@ export default function ChatView({ threadId }: ChatViewProps) { updateSettings({ lastUsedCodexFastMode: enabled, }); - // Keep the thread following the global last-used setting after the user - // changes it from the composer. - setComposerDraftCodexFastMode(threadId, null); + // Persist the thread-level selection while also updating the global + // fallback used for new threads. + setComposerDraftCodexFastMode(threadId, enabled); scheduleComposerFocus(); }, [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId, updateSettings], @@ -3520,7 +3552,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {isComposerFooterCompact ? ( Date: Fri, 13 Mar 2026 08:48:27 -0500 Subject: [PATCH 5/7] Fix web Vite config typing --- apps/web/vite.config.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 56b138d33..d6166cfb3 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,5 @@ import tailwindcss from "@tailwindcss/vite"; -import react, { reactCompilerPreset } from "@vitejs/plugin-react"; -import babel from "@rolldown/plugin-babel"; +import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { defineConfig } from "vite"; import pkg from "./package.json" with { type: "json" }; @@ -18,14 +17,12 @@ const buildSourcemap = export default defineConfig({ plugins: [ tanstackRouter(), - react(), - babel({ - // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0 - // This is because the babel plugin only automatically parses typescript and jsx based on relative paths (e.g. "**/*.ts") - // whereas the previous version of the plugin parsed all files with a .ts extension. - // This is causing our packages/ directory to fail to parse, as they are not relative to the CWD. - parserOpts: { plugins: ["typescript", "jsx"] }, - presets: [reactCompilerPreset()], + react({ + babel: { + // Keep React compiler parsing explicit for workspace files outside the app cwd. + parserOpts: { plugins: ["typescript", "jsx"] }, + plugins: ["babel-plugin-react-compiler"], + }, }), tailwindcss(), ], From a56568a705bcd826fe3859bda1822d86c8c11817 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 09:12:00 -0500 Subject: [PATCH 6/7] Fix lint issues and rebase main --- apps/web/src/components/ChatView.tsx | 11 +++++++++++ apps/web/vite.config.ts | 12 +++++------- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 066f7b8cd..18c664fab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -190,6 +190,17 @@ interface ChatViewProps { threadId: ThreadId; } +function extendReplacementRangeForTrailingSpace( + text: string, + rangeEnd: number, + replacement: string, +): number { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d6166cfb3..701c945d9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,5 +1,6 @@ import tailwindcss from "@tailwindcss/vite"; -import react from "@vitejs/plugin-react"; +import babel from "@rolldown/plugin-babel"; +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { defineConfig } from "vite"; import pkg from "./package.json" with { type: "json" }; @@ -17,12 +18,9 @@ const buildSourcemap = export default defineConfig({ plugins: [ tanstackRouter(), - react({ - babel: { - // Keep React compiler parsing explicit for workspace files outside the app cwd. - parserOpts: { plugins: ["typescript", "jsx"] }, - plugins: ["babel-plugin-react-compiler"], - }, + react(), + babel({ + presets: [reactCompilerPreset()], }), tailwindcss(), ], From 274babfc7a47ee29a34a16693ba2ddc7a0aa8474 Mon Sep 17 00:00:00 2001 From: mask Date: Fri, 13 Mar 2026 09:36:24 -0500 Subject: [PATCH 7/7] Restore web Vite config from upstream main --- apps/web/src/components/ChatView.tsx | 11 ----------- apps/web/vite.config.ts | 5 +++++ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 18c664fab..066f7b8cd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -190,17 +190,6 @@ interface ChatViewProps { threadId: ThreadId; } -function extendReplacementRangeForTrailingSpace( - text: string, - rangeEnd: number, - replacement: string, -): number { - if (!replacement.endsWith(" ")) { - return rangeEnd; - } - return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; -} - export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 701c945d9..88af16b36 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -20,6 +20,11 @@ export default defineConfig({ tanstackRouter(), react(), babel({ + // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0 + // This is because the babel plugin only automatically parses typescript and jsx based on relative paths (e.g. "**/*.ts") + // whereas the previous version of the plugin parsed all files with a .ts extension. + // This is causing our packages/ directory to fail to parse, as they are not relative to the CWD. + parserOpts: { plugins: ["typescript", "jsx"] }, presets: [reactCompilerPreset()], }), tailwindcss(),