Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";

import {
DEFAULT_CODEX_FAST_MODE,
DEFAULT_CODEX_REASONING_EFFORT,
DEFAULT_TIMESTAMP_FORMAT,
getAppModelOptions,
normalizeCustomModelSlugs,
Expand Down Expand Up @@ -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);
});
});
24 changes: 22 additions & 2 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<ProviderKind, ReadonlySet<string>> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};
Expand All @@ -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 default 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([])),
),
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
22 changes: 21 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
61 changes: 56 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ import {
LastInvokedScriptByProjectSchema,
PullRequestDialogState,
readFileAsDataUrl,
resolveComposerReasoningEffort,
revokeBlobPreviewUrl,
revokeUserMessagePreviewUrls,
SendPhase,
Expand Down Expand Up @@ -196,7 +197,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({
Expand Down Expand Up @@ -513,9 +514,47 @@ 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 defaultReasoningEffort = getDefaultReasoningEffort(selectedProvider);
const selectedEffort = resolveComposerReasoningEffort({
composerDraftEffort: composerDraft.effort,
provider: selectedProvider,
defaultCodexReasoningEffort: settings.defaultCodexReasoningEffort,
});
const selectedCodexFastModeEnabled =
selectedProvider === "codex" ? composerDraft.codexFastMode : false;
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;
Expand Down Expand Up @@ -2895,17 +2934,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
);
const onEffortSelect = useCallback(
(effort: CodexReasoningEffort) => {
updateSettings({
defaultCodexReasoningEffort: effort,
});
// Persist the thread-level selection while also updating the global
// fallback used for new threads.
setComposerDraftEffort(threadId, effort);
scheduleComposerFocus();
},
[scheduleComposerFocus, setComposerDraftEffort, threadId],
[scheduleComposerFocus, setComposerDraftEffort, threadId, updateSettings],
);
const onCodexFastModeChange = useCallback(
(enabled: boolean) => {
updateSettings({
lastUsedCodexFastMode: enabled,
});
// Persist the thread-level selection while also updating the global
// fallback used for new threads.
setComposerDraftCodexFastMode(threadId, enabled);
scheduleComposerFocus();
},
[scheduleComposerFocus, setComposerDraftCodexFastMode, threadId],
[scheduleComposerFocus, setComposerDraftCodexFastMode, threadId, updateSettings],
);
const onEnvModeChange = useCallback(
(mode: DraftThreadEnvMode) => {
Expand Down Expand Up @@ -3503,6 +3552,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
{isComposerFooterCompact ? (
<CompactComposerControlsMenu
activePlan={Boolean(activePlan || activeProposedPlan || planSidebarOpen)}
defaultEffort={defaultReasoningEffort}
interactionMode={interactionMode}
planSidebarOpen={planSidebarOpen}
runtimeMode={runtimeMode}
Expand All @@ -3525,6 +3575,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
className="mx-0.5 hidden h-4 sm:block"
/>
<CodexTraitsPicker
defaultEffort={defaultReasoningEffort}
effort={selectedEffort}
fastModeEnabled={selectedCodexFastModeEnabled}
options={reasoningOptions}
Expand Down
16 changes: 5 additions & 11 deletions apps/web/src/components/chat/CodexTraitsPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type CodexReasoningEffort } from "@t3tools/contracts";
import { getDefaultReasoningEffort } from "@t3tools/shared/model";
import { memo, useState } from "react";
import { ChevronDownIcon } from "lucide-react";
import { CODEX_REASONING_EFFORT_LABELS } from "@t3tools/contracts";
import { Button } from "../ui/button";
import {
Menu,
Expand All @@ -15,21 +15,15 @@ import {

export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
effort: CodexReasoningEffort;
defaultEffort: CodexReasoningEffort;
fastModeEnabled: boolean;
options: ReadonlyArray<CodexReasoningEffort>;
onEffortChange: (effort: CodexReasoningEffort) => void;
onFastModeChange: (enabled: boolean) => void;
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const defaultReasoningEffort = getDefaultReasoningEffort("codex");
const reasoningLabelByOption: Record<CodexReasoningEffort, string> = {
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)
Expand Down Expand Up @@ -68,8 +62,8 @@ export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
>
{props.options.map((effort) => (
<MenuRadioItem key={effort} value={effort}>
{reasoningLabelByOption[effort]}
{effort === defaultReasoningEffort ? " (default)" : ""}
{CODEX_REASONING_EFFORT_LABELS[effort]}
{effort === props.defaultEffort ? " (default)" : ""}
</MenuRadioItem>
))}
</MenuRadioGroup>
Expand Down
15 changes: 4 additions & 11 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "@t3tools/contracts";
import { Button } from "../ui/button";
import {
Menu,
Expand All @@ -21,6 +21,7 @@ import {

export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
activePlan: boolean;
defaultEffort: CodexReasoningEffort;
interactionMode: ProviderInteractionMode;
planSidebarOpen: boolean;
runtimeMode: RuntimeMode;
Expand All @@ -34,14 +35,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
onTogglePlanSidebar: () => void;
onToggleRuntimeMode: () => void;
}) {
const defaultReasoningEffort = getDefaultReasoningEffort("codex");
const reasoningLabelByOption: Record<CodexReasoningEffort, string> = {
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Extra High",
};

return (
<Menu>
<MenuTrigger
Expand Down Expand Up @@ -72,8 +65,8 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
>
{props.reasoningOptions.map((effort) => (
<MenuRadioItem key={effort} value={effort}>
{reasoningLabelByOption[effort]}
{effort === defaultReasoningEffort ? " (default)" : ""}
{CODEX_REASONING_EFFORT_LABELS[effort]}
{effort === props.defaultEffort ? " (default)" : ""}
</MenuRadioItem>
))}
</MenuRadioGroup>
Expand Down
Loading