Skip to content
Closed
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
43 changes: 28 additions & 15 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
}));
}
if (composerTrigger.kind === "slash-command") {
// Plan/default only matter when the provider honors interactionMode.
// Grok maps plan → `/plan` text on send; OpenCode and others with
// showInteractionModeToggle=false hide these no-op built-ins.
const builtInSlashCommandItems = [
{
id: "slash:model",
Expand All @@ -1092,20 +1095,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
label: "/model",
description: "Switch response model for this thread",
},
{
id: "slash:plan",
type: "slash-command",
command: "plan",
label: "/plan",
description: "Switch this thread into plan mode",
},
{
id: "slash:default",
type: "slash-command",
command: "default",
label: "/default",
description: "Switch this thread back to normal build mode",
},
...(composerProviderControls.showInteractionModeToggle
? ([
{
id: "slash:plan",
type: "slash-command",
command: "plan",
label: "/plan",
description: "Switch this thread into plan mode",
},
{
id: "slash:default",
type: "slash-command",
command: "default",
label: "/default",
description: "Switch this thread back to normal build mode",
},
] as const)
: []),
] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map(
(command) => ({
Expand Down Expand Up @@ -1140,7 +1147,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
);
}
return [];
}, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]);
}, [
composerProviderControls.showInteractionModeToggle,
composerTrigger,
selectedProvider,
selectedProviderStatus,
workspaceEntries.entries,
]);

const composerMenuOpen = Boolean(composerTrigger);
const composerMenuSearchKey = composerTrigger
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/settings/providerDriverMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
value: ProviderDriverKind.make("grok"),
label: "Grok",
icon: GrokIcon,
badgeLabel: "Early Access",
settingsSchema: GrokSettings,
},
{
Expand Down
275 changes: 275 additions & 0 deletions apps/web/src/composerDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"
import {
COMPOSER_DRAFT_STORAGE_KEY,
clearComposerDraftsEnvironment,
deriveEffectiveComposerModelState,
finalizePromotedDraftThreadByRef,
markPromotedDraftThread,
markPromotedDraftThreadByRef,
Expand Down Expand Up @@ -1155,11 +1156,285 @@ describe("composerDraftStore project draft thread mapping", () => {
describe("composerDraftStore modelSelection", () => {
const threadId = ThreadId.make("thread-model-options");
const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId);
const GROK_INSTANCE = ProviderInstanceId.make("grok");
const GROK_DRIVER = ProviderDriverKind.make("grok");

beforeEach(() => {
resetComposerDraftStore();
});

it("prefers sticky effort options over stale thread modelSelection", () => {
const derived = deriveEffectiveComposerModelState({
draft: {
modelSelectionByProvider: {},
activeProvider: null,
},
providers: [
{
instanceId: GROK_INSTANCE,
driver: GROK_DRIVER,
enabled: true,
isAvailable: true,
models: [
{
slug: "grok-4.5",
name: "Grok 4.5",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
],
} as never,
],
selectedProvider: GROK_DRIVER,
selectedInstanceId: GROK_INSTANCE,
threadModelSelection: modelSelection(GROK_DRIVER, "grok-4.5", {
reasoningEffort: "high",
}),
projectModelSelection: null,
stickyModelSelectionByProvider: {
[GROK_INSTANCE]: modelSelection(GROK_DRIVER, "grok-4.5", {
reasoningEffort: "low",
}),
},
settings: {
providers: {
grok: { customModels: [] },
},
providerInstances: {},
models: {},
} as never,
});
expect(derived.modelOptions?.[String(GROK_INSTANCE)]).toEqual(
toSelections({ reasoningEffort: "low" }),
);
});

it("ignores draft options for other instances when resolving sticky for selected", () => {
// Draft has Codex effort; selected is Grok with sticky low — must not let
// the codex map entry short-circuit sticky Grok options.
const derived = deriveEffectiveComposerModelState({
draft: {
modelSelectionByProvider: {
[CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.4", {
reasoningEffort: "high",
}),
},
activeProvider: null,
},
providers: [
{
instanceId: GROK_INSTANCE,
driver: GROK_DRIVER,
enabled: true,
isAvailable: true,
models: [
{
slug: "grok-4.5",
name: "Grok 4.5",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
],
} as never,
],
selectedProvider: GROK_DRIVER,
selectedInstanceId: GROK_INSTANCE,
threadModelSelection: modelSelection(GROK_DRIVER, "grok-4.5", {
reasoningEffort: "high",
}),
projectModelSelection: null,
stickyModelSelectionByProvider: {
[GROK_INSTANCE]: modelSelection(GROK_DRIVER, "grok-4.5", {
reasoningEffort: "low",
}),
},
settings: {
providers: {
grok: { customModels: [] },
},
providerInstances: {},
models: {},
} as never,
});
expect(derived.modelOptions?.[String(GROK_INSTANCE)]).toEqual(
toSelections({ reasoningEffort: "low" }),
);
expect(derived.modelOptions?.[String(CODEX_INSTANCE)]).toBeUndefined();
});

it("does not let kind-keyed draft options outrank sticky when instance entry has no options", () => {
// Selected custom Codex instance has a model-only draft entry; kind-keyed
// codex draft holds high effort. Sticky for the custom instance is low —
// sticky must win (same rule as the model path: legacy only when entry missing).
const derived = deriveEffectiveComposerModelState({
draft: {
modelSelectionByProvider: {
[CODEX_SECONDARY_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.4"),
[CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
reasoningEffort: "high",
}),
},
activeProvider: null,
},
providers: [
{
instanceId: CODEX_SECONDARY_INSTANCE,
driver: CODEX_DRIVER,
enabled: true,
isAvailable: true,
models: [
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
],
} as never,
],
selectedProvider: CODEX_DRIVER,
selectedInstanceId: CODEX_SECONDARY_INSTANCE,
threadModelSelection: null,
projectModelSelection: null,
stickyModelSelectionByProvider: {
[CODEX_SECONDARY_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.4", {
reasoningEffort: "low",
}),
},
settings: {
providers: {
codex: { customModels: [] },
},
providerInstances: {},
models: {},
} as never,
});
expect(derived.modelOptions?.[String(CODEX_SECONDARY_INSTANCE)]).toEqual(
toSelections({ reasoningEffort: "low" }),
);
});

it("uses sticky options only and keeps thread model when draft is empty", () => {
const derived = deriveEffectiveComposerModelState({
draft: {
modelSelectionByProvider: {},
activeProvider: null,
},
providers: [
{
instanceId: GROK_INSTANCE,
driver: GROK_DRIVER,
enabled: true,
isAvailable: true,
models: [
{
slug: "grok-4.5",
name: "Grok 4.5",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
{
slug: "grok-4",
name: "Grok 4",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
],
} as never,
],
selectedProvider: GROK_DRIVER,
selectedInstanceId: GROK_INSTANCE,
threadModelSelection: modelSelection(GROK_DRIVER, "grok-4.5", {
reasoningEffort: "high",
}),
projectModelSelection: null,
stickyModelSelectionByProvider: {
[GROK_INSTANCE]: modelSelection(GROK_DRIVER, "grok-4", {
reasoningEffort: "low",
}),
},
settings: {
providers: {
grok: { customModels: [] },
},
providerInstances: {},
models: {},
} as never,
});
expect(derived.selectedModel).toBe("grok-4.5");
expect(derived.modelOptions?.[String(GROK_INSTANCE)]).toEqual(
toSelections({ reasoningEffort: "low" }),
);
});

it("re-keys sticky options under selected custom instance", () => {
const derived = deriveEffectiveComposerModelState({
draft: {
modelSelectionByProvider: {},
activeProvider: null,
},
providers: [
{
instanceId: CODEX_SECONDARY_INSTANCE,
driver: CODEX_DRIVER,
enabled: true,
isAvailable: true,
models: [
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
capabilities: { optionDescriptors: [] },
},
],
} as never,
],
selectedProvider: CODEX_DRIVER,
selectedInstanceId: CODEX_SECONDARY_INSTANCE,
threadModelSelection: modelSelection(CODEX_DRIVER, "gpt-5.4", {
reasoningEffort: "high",
}),
projectModelSelection: null,
stickyModelSelectionByProvider: {
[CODEX_INSTANCE]: modelSelection(CODEX_DRIVER, "gpt-5.3-codex", {
reasoningEffort: "low",
}),
},
settings: {
providers: {
codex: { customModels: [] },
},
providerInstances: {},
models: {},
} as never,
});
expect(derived.selectedModel).toBe("gpt-5.4");
expect(derived.modelOptions?.[String(CODEX_SECONDARY_INSTANCE)]).toEqual(
toSelections({ reasoningEffort: "low" }),
);
expect(derived.modelOptions?.[String(CODEX_INSTANCE)]).toBeUndefined();
});

it("persists grok option selections on the draft and sticky map", () => {
const store = useComposerDraftStore.getState();
store.setProviderModelOptions(
threadRef,
GROK_DRIVER,
toSelections({ reasoningEffort: "low" }),
{
instanceId: GROK_INSTANCE,
model: "grok-4.5",
persistSticky: true,
},
);
expect(
draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider[GROK_INSTANCE],
).toEqual(modelSelection(GROK_DRIVER, "grok-4.5", { reasoningEffort: "low" }));
expect(useComposerDraftStore.getState().stickyModelSelectionByProvider[GROK_INSTANCE]).toEqual(
modelSelection(GROK_DRIVER, "grok-4.5", { reasoningEffort: "low" }),
);
});

it("stores a model selection in the draft", () => {
const store = useComposerDraftStore.getState();
store.setModelSelection(
Expand Down
Loading
Loading