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
130 changes: 130 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
stickyModel: null,
stickyModelOptions: {},
});
useStore.setState({
projects: [],
Expand Down Expand Up @@ -1277,6 +1279,134 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("snapshots sticky codex settings into a new draft thread", async () => {
useComposerDraftStore.setState({
stickyModel: "gpt-5.3-codex",
stickyModelOptions: {
codex: {
reasoningEffort: "medium",
fastMode: 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({
model: "gpt-5.3-codex",
effort: "medium",
codexFastMode: true,
});
} finally {
await mounted.cleanup();
}
});

it("falls back to defaults when no sticky composer settings exist", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetUser({
targetMessageId: "msg-user-default-codex-traits-test" as MessageId,
targetText: "default 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]).toBeUndefined();
} finally {
await mounted.cleanup();
}
});

it("prefers draft state over sticky composer settings and defaults", async () => {
useComposerDraftStore.setState({
stickyModel: "gpt-5.3-codex",
stickyModelOptions: {
codex: {
reasoningEffort: "medium",
fastMode: true,
},
},
});

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

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

await newThreadButton.click();

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

expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
model: "gpt-5.3-codex",
effort: "medium",
codexFastMode: true,
});

useComposerDraftStore.getState().setModel(threadId, "gpt-5.4");
useComposerDraftStore.getState().setEffort(threadId, "low");

await newThreadButton.click();

await waitForURL(
mounted.router,
(path) => path === threadPath,
"New-thread should reuse the existing project draft thread.",
);
expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
model: "gpt-5.4",
effort: "low",
codexFastMode: 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
42 changes: 36 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
const { settings } = useAppSettings();
const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);
const setStickyComposerModelOptions = useComposerDraftStore(
(store) => store.setStickyModelOptions,
);
const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
Expand Down Expand Up @@ -3048,11 +3052,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") {
setStickyComposerModel(resolvedModel);
}
scheduleComposerFocus();
},
[
Expand All @@ -3061,22 +3066,47 @@ export default function ChatView({ threadId }: ChatViewProps) {
scheduleComposerFocus,
setComposerDraftModel,
setComposerDraftProvider,
setStickyComposerModel,
settings.customCodexModels,
],
);
const onEffortSelect = useCallback(
(effort: CodexReasoningEffort) => {
setComposerDraftEffort(threadId, effort);
setStickyComposerModelOptions({
codex: {
reasoningEffort: effort,
...(selectedCodexFastModeEnabled ? { fastMode: true } : {}),
},
});
scheduleComposerFocus();
},
[scheduleComposerFocus, setComposerDraftEffort, threadId],
[
scheduleComposerFocus,
selectedCodexFastModeEnabled,
setComposerDraftEffort,
setStickyComposerModelOptions,
threadId,
],
);
const onCodexFastModeChange = useCallback(
(enabled: boolean) => {
setComposerDraftCodexFastMode(threadId, enabled);
setStickyComposerModelOptions({
codex: {
reasoningEffort: selectedEffort,
...(enabled ? { fastMode: true } : {}),
},
});
scheduleComposerFocus();
},
[scheduleComposerFocus, setComposerDraftCodexFastMode, threadId],
[
scheduleComposerFocus,
selectedEffort,
setComposerDraftCodexFastMode,
setStickyComposerModelOptions,
threadId,
],
);
const onEnvModeChange = useCallback(
(mode: DraftThreadEnvMode) => {
Expand Down
92 changes: 57 additions & 35 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,23 @@ function makeTerminalContext(input: {
};
}

function resetComposerDraftStore() {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
stickyModel: null,
stickyModelOptions: {},
});
}

describe("composerDraftStore addImages", () => {
const threadId = ThreadId.makeUnsafe("thread-dedupe");
let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;

beforeEach(() => {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
resetComposerDraftStore();
originalRevokeObjectUrl = URL.revokeObjectURL;
revokeSpy = vi.fn();
URL.revokeObjectURL = revokeSpy;
Expand Down Expand Up @@ -154,11 +160,7 @@ describe("composerDraftStore clearComposerContent", () => {
let revokeSpy: ReturnType<typeof vi.fn<(url: string) => void>>;

beforeEach(() => {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
resetComposerDraftStore();
originalRevokeObjectUrl = URL.revokeObjectURL;
revokeSpy = vi.fn();
URL.revokeObjectURL = revokeSpy;
Expand Down Expand Up @@ -332,11 +334,7 @@ describe("composerDraftStore project draft thread mapping", () => {
const otherThreadId = ThreadId.makeUnsafe("thread-b");

beforeEach(() => {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
resetComposerDraftStore();
});

it("stores and reads project draft thread ids via actions", () => {
Expand Down Expand Up @@ -508,11 +506,7 @@ describe("composerDraftStore codex fast mode", () => {
const threadId = ThreadId.makeUnsafe("thread-service-tier");

beforeEach(() => {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
resetComposerDraftStore();
});

it("stores codex fast mode in the draft", () => {
Expand All @@ -535,11 +529,7 @@ describe("composerDraftStore setModel", () => {
const threadId = ThreadId.makeUnsafe("thread-model");

beforeEach(() => {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
resetComposerDraftStore();
});

it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => {
Expand All @@ -553,15 +543,51 @@ describe("composerDraftStore setModel", () => {
});
});

describe("composerDraftStore sticky composer settings", () => {
beforeEach(() => {
resetComposerDraftStore();
});

it("stores sticky model and codex model options", () => {
const store = useComposerDraftStore.getState();

store.setStickyModel("gpt-5.3-codex");
store.setStickyModelOptions({
codex: {
reasoningEffort: "medium",
fastMode: true,
},
});

expect(useComposerDraftStore.getState()).toMatchObject({
stickyModel: "gpt-5.3-codex",
stickyModelOptions: {
codex: {
reasoningEffort: "medium",
fastMode: true,
},
},
});
});

it("normalizes empty sticky model options", () => {
const store = useComposerDraftStore.getState();

store.setStickyModelOptions({
codex: {
fastMode: false,
},
});

expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({});
});
});

describe("composerDraftStore setProvider", () => {
const threadId = ThreadId.makeUnsafe("thread-provider");

beforeEach(() => {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
resetComposerDraftStore();
});

it("persists provider-only selection even when prompt/model are empty", () => {
Expand All @@ -586,11 +612,7 @@ describe("composerDraftStore runtime and interaction settings", () => {
const threadId = ThreadId.makeUnsafe("thread-settings");

beforeEach(() => {
useComposerDraftStore.setState({
draftsByThreadId: {},
draftThreadsByThreadId: {},
projectDraftThreadIdByProjectId: {},
});
resetComposerDraftStore();
});

it("stores runtime mode overrides in the composer draft", () => {
Expand Down
Loading