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
42 changes: 42 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,48 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("snapshots sticky codex traits into a new draft thread", async () => {
localStorage.setItem(
"t3code:sticky-composer-settings:v1",
JSON.stringify({
model: null,
effort: "medium",
codexFastMode: true,
}),
);

const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-sticky-codex-traits-test" as MessageId,
targetText: "sticky codex traits test",
}),
});

try {
const newThreadButton = page.getByTestId("new-thread-button");
await expect.element(newThreadButton).toBeInTheDocument();

await newThreadButton.click();

const newThreadPath = await waitForURL(
mounted.router,
(path) => UUID_ROUTE_RE.test(path),
"Route should have changed to a new draft thread UUID.",
);
const newThreadId = newThreadPath.slice(1) as ThreadId;

expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
effort: "medium",
codexFastMode: true,
hasEffortOverride: true,
hasCodexFastModeOverride: true,
});
} finally {
await mounted.cleanup();
}
});

it("creates a new thread from the global chat.new shortcut", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ import {
useComposerDraftStore,
useComposerThreadDraft,
} from "../composerDraftStore";
import { useStickyComposerSettings } from "../stickyComposerSettings";
import { shouldUseCompactComposerFooter } from "./composerFooterLayout";
import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
Expand Down Expand Up @@ -197,6 +198,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
const { settings } = useAppSettings();
const { updateSettings: updateStickyComposerSettings } = useStickyComposerSettings();
const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
Expand Down Expand Up @@ -2879,11 +2881,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
scheduleComposerFocus();
return;
}
const resolvedModel = resolveAppModelSelection(provider, settings.customCodexModels, model);
setComposerDraftProvider(activeThread.id, provider);
setComposerDraftModel(
activeThread.id,
resolveAppModelSelection(provider, settings.customCodexModels, model),
);
setComposerDraftModel(activeThread.id, resolvedModel);
if (provider === "codex") {
updateStickyComposerSettings({ model: resolvedModel });
}
scheduleComposerFocus();
},
[
Expand All @@ -2893,21 +2896,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
setComposerDraftModel,
setComposerDraftProvider,
settings.customCodexModels,
updateStickyComposerSettings,
],
);
const onEffortSelect = useCallback(
(effort: CodexReasoningEffort) => {
setComposerDraftEffort(threadId, effort);
updateStickyComposerSettings({ effort });
scheduleComposerFocus();
},
[scheduleComposerFocus, setComposerDraftEffort, threadId],
[scheduleComposerFocus, setComposerDraftEffort, threadId, updateStickyComposerSettings],
);
const onCodexFastModeChange = useCallback(
(enabled: boolean) => {
setComposerDraftCodexFastMode(threadId, enabled);
updateStickyComposerSettings({ codexFastMode: enabled });
scheduleComposerFocus();
},
[scheduleComposerFocus, setComposerDraftCodexFastMode, threadId],
[scheduleComposerFocus, setComposerDraftCodexFastMode, threadId, updateStickyComposerSettings],
);
const onEnvModeChange = useCallback(
(mode: DraftThreadEnvMode) => {
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,12 +355,15 @@ describe("composerDraftStore codex fast mode", () => {
expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true);
});

it("clears codex fast mode when reset to the default", () => {
it("keeps an explicit codex fast mode override when reset to false", () => {
const store = useComposerDraftStore.getState();
store.setCodexFastMode(threadId, true);
store.setCodexFastMode(threadId, false);

expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
codexFastMode: false,
hasCodexFastModeOverride: true,
});
});
});

Expand Down
45 changes: 34 additions & 11 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ interface PersistedComposerThreadDraftState {
interactionMode?: ProviderInteractionMode | null;
effort?: CodexReasoningEffort | null;
codexFastMode?: boolean | null;
hasEffortOverride?: boolean | null;
hasCodexFastModeOverride?: boolean | null;
serviceTier?: string | null;
}

Expand Down Expand Up @@ -110,6 +112,8 @@ interface ComposerThreadDraftState {
interactionMode: ProviderInteractionMode | null;
effort: CodexReasoningEffort | null;
codexFastMode: boolean;
hasEffortOverride: boolean;
hasCodexFastModeOverride: boolean;
}

export interface DraftThreadState {
Expand Down Expand Up @@ -204,6 +208,8 @@ const EMPTY_THREAD_DRAFT = Object.freeze({
interactionMode: null,
effort: null,
codexFastMode: false,
hasEffortOverride: false,
hasCodexFastModeOverride: false,
}) as ComposerThreadDraftState;

const REASONING_EFFORT_VALUES = new Set<CodexReasoningEffort>(
Expand All @@ -222,6 +228,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
interactionMode: null,
effort: null,
codexFastMode: false,
hasEffortOverride: false,
hasCodexFastModeOverride: false,
};
}

Expand All @@ -241,7 +249,9 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
draft.runtimeMode === null &&
draft.interactionMode === null &&
draft.effort === null &&
draft.codexFastMode === false
draft.codexFastMode === false &&
draft.hasEffortOverride === false &&
draft.hasCodexFastModeOverride === false
);
}

Expand Down Expand Up @@ -427,6 +437,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
const codexFastMode =
draftCandidate.codexFastMode === true ||
(typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast");
const hasEffortOverride = draftCandidate.hasEffortOverride === true || effort !== null;
const hasCodexFastModeOverride =
draftCandidate.hasCodexFastModeOverride === true || codexFastMode;
if (
prompt.length === 0 &&
attachments.length === 0 &&
Expand All @@ -435,7 +448,9 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
!runtimeMode &&
!interactionMode &&
!effort &&
!codexFastMode
!codexFastMode &&
!hasEffortOverride &&
!hasCodexFastModeOverride
) {
continue;
}
Expand All @@ -448,6 +463,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
...(interactionMode ? { interactionMode } : {}),
...(effort ? { effort } : {}),
...(codexFastMode ? { codexFastMode } : {}),
...(hasEffortOverride ? { hasEffortOverride } : {}),
...(hasCodexFastModeOverride ? { hasCodexFastModeOverride } : {}),
};
}
return {
Expand Down Expand Up @@ -554,6 +571,8 @@ function toHydratedThreadDraft(
interactionMode: persistedDraft.interactionMode ?? null,
effort: persistedDraft.effort ?? null,
codexFastMode: persistedDraft.codexFastMode === true,
hasEffortOverride: persistedDraft.hasEffortOverride === true,
hasCodexFastModeOverride: persistedDraft.hasCodexFastModeOverride === true,
};
}

Expand Down Expand Up @@ -939,16 +958,14 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
: null;
set((state) => {
const existing = state.draftsByThreadId[threadId];
if (!existing && nextEffort === null) {
return state;
}
const base = existing ?? createEmptyThreadDraft();
if (base.effort === nextEffort) {
if (base.effort === nextEffort && base.hasEffortOverride) {
return state;
}
const nextDraft: ComposerThreadDraftState = {
...base,
effort: nextEffort,
hasEffortOverride: true,
};
const nextDraftsByThreadId = { ...state.draftsByThreadId };
if (shouldRemoveDraft(nextDraft)) {
Expand All @@ -966,16 +983,14 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
const nextCodexFastMode = enabled === true;
set((state) => {
const existing = state.draftsByThreadId[threadId];
if (!existing && nextCodexFastMode === false) {
return state;
}
const base = existing ?? createEmptyThreadDraft();
if (base.codexFastMode === nextCodexFastMode) {
if (base.codexFastMode === nextCodexFastMode && base.hasCodexFastModeOverride) {
return state;
}
const nextDraft: ComposerThreadDraftState = {
...base,
codexFastMode: nextCodexFastMode,
hasCodexFastModeOverride: true,
};
const nextDraftsByThreadId = { ...state.draftsByThreadId };
if (shouldRemoveDraft(nextDraft)) {
Expand Down Expand Up @@ -1223,7 +1238,9 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
draft.runtimeMode === null &&
draft.interactionMode === null &&
draft.effort === null &&
draft.codexFastMode === false
draft.codexFastMode === false &&
draft.hasEffortOverride === false &&
draft.hasCodexFastModeOverride === false
) {
continue;
}
Expand All @@ -1249,6 +1266,12 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
if (draft.codexFastMode) {
persistedDraft.codexFastMode = true;
}
if (draft.hasEffortOverride) {
persistedDraft.hasEffortOverride = true;
}
if (draft.hasCodexFastModeOverride) {
persistedDraft.hasCodexFastModeOverride = true;
}
persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
}
return {
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/hooks/useHandleNewThread.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
import { getDefaultReasoningEffort } from "@t3tools/shared/model";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useCallback } from "react";
import {
type DraftThreadEnvMode,
type DraftThreadState,
useComposerDraftStore,
} from "../composerDraftStore";
import { useStickyComposerSettings } from "../stickyComposerSettings";
import { newThreadId } from "../lib/utils";
import { useStore } from "../store";

export function useHandleNewThread() {
const projects = useStore((store) => store.projects);
const threads = useStore((store) => store.threads);
const {
settings: { model: stickyModel, effort: stickyEffort, codexFastMode: stickyCodexFastMode },
} = useStickyComposerSettings();
const navigate = useNavigate();
const routeThreadId = useParams({
strict: false,
Expand All @@ -38,6 +43,9 @@ export function useHandleNewThread() {
clearProjectDraftThreadId,
getDraftThread,
getDraftThreadByProjectId,
setCodexFastMode,
setEffort,
setModel,
setDraftThreadContext,
setProjectDraftThreadId,
} = useComposerDraftStore.getState();
Expand Down Expand Up @@ -96,14 +104,19 @@ export function useHandleNewThread() {
envMode: options?.envMode ?? "local",
runtimeMode: DEFAULT_RUNTIME_MODE,
});
if (stickyModel) {
setModel(threadId, stickyModel);
}
setEffort(threadId, stickyEffort ?? getDefaultReasoningEffort("codex"));
setCodexFastMode(threadId, stickyCodexFastMode);

await navigate({
to: "/$threadId",
params: { threadId },
});
})();
},
[navigate, routeThreadId],
[navigate, routeThreadId, stickyCodexFastMode, stickyEffort, stickyModel],
);

return {
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/stickyComposerSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type CodexReasoningEffort, CODEX_REASONING_EFFORT_OPTIONS } from "@t3tools/contracts";
import { normalizeModelSlug } from "@t3tools/shared/model";
import { Schema } from "effect";
import { useCallback } from "react";
import { useLocalStorage } from "./hooks/useLocalStorage";

const STICKY_COMPOSER_SETTINGS_STORAGE_KEY = "t3code:sticky-composer-settings:v1";

const StickyComposerSettingsSchema = Schema.Struct({
model: Schema.NullOr(Schema.String),
effort: Schema.NullOr(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)),
codexFastMode: Schema.Boolean,
});

export type StickyComposerSettings = typeof StickyComposerSettingsSchema.Type;

const DEFAULT_STICKY_COMPOSER_SETTINGS: StickyComposerSettings = {
model: null,
effort: null,
codexFastMode: false,
};

function normalizeStickyComposerSettings(
value: Partial<StickyComposerSettings> | StickyComposerSettings,
): StickyComposerSettings {
const effort = value.effort;
return {
model: normalizeModelSlug(value.model, "codex") ?? null,
effort:
typeof effort === "string" &&
(CODEX_REASONING_EFFORT_OPTIONS as readonly string[]).includes(effort)
? (effort as CodexReasoningEffort)
: null,
codexFastMode: value.codexFastMode === true,
};
}

export function useStickyComposerSettings() {
const [settings, setSettings] = useLocalStorage(
STICKY_COMPOSER_SETTINGS_STORAGE_KEY,
DEFAULT_STICKY_COMPOSER_SETTINGS,
StickyComposerSettingsSchema,
);

const updateSettings = useCallback(
(patch: Partial<StickyComposerSettings>) => {
setSettings((previous) => normalizeStickyComposerSettings({ ...previous, ...patch }));
},
[setSettings],
);

return {
settings,
updateSettings,
defaults: DEFAULT_STICKY_COMPOSER_SETTINGS,
} as const;
}