From 7f3e325c4672cfd23282d5a7f4bcf7ea837b9dd3 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:46:32 -0700 Subject: [PATCH] feat(chat): take a free-typed custom model id in the model picker A brand-new model is often live on the wire days before an agent's curated list catches up (claude-fable-5-1 answered while the published picker list still lacked it), and the stack already handles out-of-picker ids: the claude adapter deliberately tracks the raw id of a model outside its options, and every codeg trigger falls back to rendering that raw id. The only missing piece was a place to type one. Each model-picker surface (the inline dropdown, the searchable popover, and the collapsed cog panel) now ends in a "Use custom model ID..." row opening a small entry dialog. The trimmed id takes the exact path an advertised pick takes: optimistic apply, saved preference, and session/set_config_option (Grok's set_model variant included), so an agent that adopts it runs it, one that settles elsewhere raises the existing config_option_rejected toast naming the typed id, and the preference replays on the next connect. Model option only, no per-agent code, no validation beyond trim. One rejection shape was silent before: an agent that refuses the set outright (a JSON-RPC error, e.g. an id claude cannot resolve) emitted only a recoverable Error, so the composer kept showing an optimistic value the agent never ran. A failed set now re-emits the last adopted option list first, the same revert-then-report recovery the Grok cross-agent-type switch already uses. Sibling options stay honest for free: an agent answering a model switch with a rebuilt list (claude seeds effort levels per model, and a custom id carries none, so effort drops out) replaces the selector wholesale, and a new test pins that adoption. --- src-tauri/src/acp/connection.rs | 104 +++++++ .../chat/custom-model-id-dialog.tsx | 91 ++++++ src/components/chat/message-input.test.tsx | 288 ++++++++++++++++++ src/components/chat/message-input.tsx | 44 +++ src/components/chat/model-option-picker.tsx | 21 +- .../chat/session-config-selector.test.tsx | 41 +++ .../chat/session-config-selector.tsx | 21 ++ .../chat/session-selectors-panel.tsx | 50 ++- src/contexts/acp-connections-context.test.tsx | 66 +++- src/i18n/messages/ar.json | 6 + src/i18n/messages/de.json | 6 + src/i18n/messages/en.json | 6 + src/i18n/messages/es.json | 6 + src/i18n/messages/fr.json | 6 + src/i18n/messages/ja.json | 6 + src/i18n/messages/ko.json | 6 + src/i18n/messages/pt.json | 6 + src/i18n/messages/zh-CN.json | 6 + src/i18n/messages/zh-TW.json | 6 + 19 files changed, 780 insertions(+), 6 deletions(-) create mode 100644 src/components/chat/custom-model-id-dialog.tsx diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 581e190642..8cb2d9e6bd 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -3499,6 +3499,26 @@ async fn emit_grok_incompatible_agent_switch( .await; } +/// Revert the composer's optimistic selection after a `set_config_option` the +/// agent refused OUTRIGHT (a JSON-RPC error — e.g. a free-typed model id the +/// agent won't accept). A refusal carries no settling option list, so without +/// this the client keeps showing the value it applied optimistically while the +/// agent runs something else. Re-emitting the last ADOPTED list (`apply_event` +/// stores every broadcast into `state.config_options`, and the failed set never +/// got one) snaps every surface back to the truth. Same recovery shape as +/// [`emit_grok_incompatible_agent_switch`]; a no-op before the first list. +async fn revert_config_options_after_failed_set( + state: &Arc>, + emitter: &EventEmitter, +) { + // Clone out of the read guard BEFORE emitting — the `emit_*` helpers take + // this same state's write lock (see the deadlock note on the Grok twin). + let current = state.read().await.config_options.clone(); + if let Some(opts) = current { + emit_session_config_options_info(state, emitter, opts).await; + } +} + /// Emit the composer's session config-option selectors. For Grok this reads the /// synthesized `x.ai/sessionConfig` (parity path); for every other agent it runs /// the standard preference-application + sacp-mapping pipeline unchanged. @@ -8651,6 +8671,13 @@ async fn run_conversation_loop<'a>( .await }; if let Err(e) = set_result { + // Revert first, then report — the + // composer snaps back before the toast + // appears (see the Grok twin's test). + revert_config_options_after_failed_set( + state, emitter, + ) + .await; emit_with_state( state, emitter, @@ -8924,6 +8951,9 @@ async fn run_conversation_loop<'a>( set_session_config_option(&cx, &sid, state, emitter, config_id, value_id).await }; if let Err(e) = set_result { + // Revert first, then report — the composer snaps back + // before the toast appears (see the Grok twin's test). + revert_config_options_after_failed_set(state, emitter).await; emit_with_state( state, emitter, @@ -17023,6 +17053,80 @@ mod tests { assert!(!errors[0].1, "recoverable, not terminal"); } + #[tokio::test] + async fn failed_set_config_option_revert_reemits_last_adopted_options() { + use std::time::Duration; + + // The composer optimistically applied a free-typed model id and the + // agent refused the set with a JSON-RPC error — no settling option + // list follows, so the revert must re-emit the last ADOPTED list. + let mut st = SessionState::new( + "conn-test".to_string(), + AgentType::ClaudeCode, + None, + "win".to_string(), + None, + ); + st.config_options = Some(grok_model_options("grok-4.5")); + let state = Arc::new(RwLock::new(st)); + let emitter = EventEmitter::Noop; + + // Same deadlock guard as the Grok twin: the helper must clone the + // options out of the read guard before `emit_*` takes the write lock. + tokio::time::timeout( + Duration::from_secs(5), + revert_config_options_after_failed_set(&state, &emitter), + ) + .await + .expect("revert must complete, not deadlock on the state lock"); + + let guard = state.read().await; + let events = guard.recent_events_after(0).expect("events recorded"); + let reverted = events + .iter() + .find_map(|e| match &e.payload { + AcpEvent::SessionConfigOptions { config_options } => { + Some(config_options.clone()) + } + _ => None, + }) + .expect("a session_config_options revert is emitted"); + // The re-broadcast carries the adopted value, not the refused pick. + let sel = expect_select(&reverted[0].kind); + assert_eq!(sel.current_value, "grok-4.5"); + } + + #[tokio::test] + async fn failed_set_config_option_revert_is_a_noop_before_the_first_list() { + // A refusal can race the very first `session_config_options` (prefs + // applied at connect). With nothing adopted yet there is nothing + // truthful to re-emit — the revert must stay silent, not broadcast an + // empty selector list that would blank the composer. + let st = SessionState::new( + "conn-test".to_string(), + AgentType::ClaudeCode, + None, + "win".to_string(), + None, + ); + assert!(st.config_options.is_none()); + let state = Arc::new(RwLock::new(st)); + let emitter = EventEmitter::Noop; + + revert_config_options_after_failed_set(&state, &emitter).await; + + let guard = state.read().await; + let no_config_emit = guard + .recent_events_after(0) + .map(|events| { + !events.iter().any(|e| { + matches!(&e.payload, AcpEvent::SessionConfigOptions { .. }) + }) + }) + .unwrap_or(true); + assert!(no_config_emit, "nothing adopted yet, nothing to re-emit"); + } + #[test] fn grok_live_tool_output_prefers_content() { // The clean content channel carries the output → don't ship raw_output diff --git a/src/components/chat/custom-model-id-dialog.tsx b/src/components/chat/custom-model-id-dialog.tsx new file mode 100644 index 0000000000..6388386160 --- /dev/null +++ b/src/components/chat/custom-model-id-dialog.tsx @@ -0,0 +1,91 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useImeGuard } from "@/hooks/use-ime-guard" + +interface CustomModelIdDialogProps { + open: boolean + onClose: () => void + /** Called with the trimmed id. The caller routes it through the exact same + * path as picking an advertised option. */ + onSubmit: (modelId: string) => void +} + +// Free-text entry behind the model picker's "Use custom model id..." row. A +// brand-new model is often live on the wire days before the agent's curated +// list catches up, so the only thing between the user and the model is a place +// to type the id. Deliberately no validation beyond trim/non-empty: the id is +// sent verbatim and the agent's own verdict — adopt it, settle somewhere else +// (`config_option_rejected`), or refuse the set outright — comes back through +// the existing config-option flow, for every agent alike. +export function CustomModelIdDialog({ + open, + onClose, + onSubmit, +}: CustomModelIdDialogProps) { + const t = useTranslations("Folder.chat.messageInput") + const ime = useImeGuard() + const [value, setValue] = useState("") + const trimmed = value.trim() + + function handleClose() { + setValue("") + onClose() + } + + function handleSubmit() { + if (!trimmed) return + setValue("") + onSubmit(trimmed) + } + + return ( + !v && handleClose()}> + + + {t("customModelTitle")} + {t("customModelDescription")} + + +
+ + setValue(e.target.value)} + {...ime.props} + onKeyDown={(e) => { + if (ime.isComposing(e)) return + if (e.key === "Enter") handleSubmit() + }} + spellCheck={false} + autoComplete="off" + autoFocus + /> +
+ + + + + +
+
+ ) +} diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index c469de545e..dfdba8ce45 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -797,6 +797,294 @@ describe("MessageInput collapsed selectors popover", () => { }) }) +// The model picker's free-text escape hatch: a brand-new model is often live on +// the wire before the agent's curated list catches up, so every model-picker +// surface offers a trailing "Use custom model ID..." row that opens a small +// entry dialog. The typed id then takes the exact same +// `onConfigOptionChange` path as picking an advertised option. +describe("MessageInput custom model id entry", () => { + afterEach(() => cleanup()) + + const LONG_MODEL_OPTION: SessionConfigOptionInfo = { + id: "model", + name: "Model", + description: null, + category: null, + kind: { + type: "select", + current_value: "openrouter/model-0", + options: Array.from({ length: 30 }, (_, i) => ({ + value: `openrouter/model-${i}`, + name: `openrouter/model-${i}`, + description: null, + })), + groups: [], + }, + } + + it("routes a typed id from the inline dropdown through the option path", async () => { + const user = userEvent.setup() + const onConfigOptionChange = vi.fn() + const { container } = renderInput({ + configOptions: [MODEL_OPTION], + onConfigOptionChange, + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + await user.click(screen.getByRole("button", { name: "Model: Default" })) + await user.click( + await screen.findByRole("menuitem", { name: MSGS.customModelEntry }) + ) + + const dialog = await screen.findByRole("dialog", { + name: MSGS.customModelTitle, + }) + await user.type( + within(dialog).getByLabelText(MSGS.customModelInputLabel), + "claude-fable-5-1" + ) + await user.click( + within(dialog).getByRole("button", { name: MSGS.customModelApply }) + ) + + expect(onConfigOptionChange).toHaveBeenCalledWith( + "model", + "claude-fable-5-1" + ) + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: MSGS.customModelTitle }) + ).toBeNull() + ) + }) + + it("submits on Enter with the id trimmed, and blocks blank ids", async () => { + const user = userEvent.setup() + const onConfigOptionChange = vi.fn() + const { container } = renderInput({ + configOptions: [MODEL_OPTION], + onConfigOptionChange, + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + await user.click(screen.getByRole("button", { name: "Model: Default" })) + await user.click( + await screen.findByRole("menuitem", { name: MSGS.customModelEntry }) + ) + const dialog = await screen.findByRole("dialog", { + name: MSGS.customModelTitle, + }) + const input = within(dialog).getByLabelText(MSGS.customModelInputLabel) + const apply = within(dialog).getByRole("button", { + name: MSGS.customModelApply, + }) + + // Whitespace is not an id: the submit stays disabled and Enter is inert. + expect(apply).toBeDisabled() + await user.type(input, " ") + expect(apply).toBeDisabled() + await user.keyboard("{Enter}") + expect(onConfigOptionChange).not.toHaveBeenCalled() + + // A real id submits on Enter, trimmed of the stray whitespace. + await user.clear(input) + await user.type(input, " claude-fable-5-1 ") + await user.keyboard("{Enter}") + expect(onConfigOptionChange).toHaveBeenCalledWith( + "model", + "claude-fable-5-1" + ) + }) + + it("keeps Cancel side-effect-free and reopens with a blank field", async () => { + const user = userEvent.setup() + const onConfigOptionChange = vi.fn() + const { container } = renderInput({ + configOptions: [MODEL_OPTION], + onConfigOptionChange, + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + await user.click(screen.getByRole("button", { name: "Model: Default" })) + await user.click( + await screen.findByRole("menuitem", { name: MSGS.customModelEntry }) + ) + let dialog = await screen.findByRole("dialog", { + name: MSGS.customModelTitle, + }) + await user.type( + within(dialog).getByLabelText(MSGS.customModelInputLabel), + "half-typed" + ) + await user.click(within(dialog).getByRole("button", { name: MSGS.cancel })) + expect(onConfigOptionChange).not.toHaveBeenCalled() + + // The abandoned draft does not survive into the next open. + await user.click(screen.getByRole("button", { name: "Model: Default" })) + await user.click( + await screen.findByRole("menuitem", { name: MSGS.customModelEntry }) + ) + dialog = await screen.findByRole("dialog", { + name: MSGS.customModelTitle, + }) + expect( + within(dialog).getByLabelText(MSGS.customModelInputLabel) + ).toHaveValue("") + }) + + it("offers the entry only on the MODEL option, never other selects", async () => { + const user = userEvent.setup() + const effortOption: SessionConfigOptionInfo = { + id: "effort", + name: "Effort", + description: null, + category: "thought_level", + kind: { + type: "select", + current_value: "default", + options: [ + { value: "default", name: "Default", description: null }, + { value: "high", name: "High", description: null }, + ], + groups: [], + }, + } + const { container } = renderInput({ + configOptions: [effortOption], + onConfigOptionChange: vi.fn(), + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + await user.click(screen.getByRole("button", { name: "Effort: Default" })) + // The rows themselves render (it's a real select)… + expect( + await screen.findByRole("menuitemradio", { name: /High/ }) + ).toBeInTheDocument() + // …but the free-text escape hatch is a model-picker affordance only. + expect( + screen.queryByRole("menuitem", { name: MSGS.customModelEntry }) + ).toBeNull() + }) + + it("pins the entry under the searchable popover, beyond any filter", async () => { + const user = userEvent.setup() + const onConfigOptionChange = vi.fn() + const { container } = renderInput({ + configOptions: [LONG_MODEL_OPTION], + onConfigOptionChange, + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + // The wide surface for a long list is the searchable popover. (The shared + // `openrouter/` prefix is stripped from the trigger label — the provider + // is implied by the group the model sits in.) + await user.click(screen.getByRole("button", { name: "Model: model-0" })) + const search = await screen.findByRole("combobox") + // A brand-new id matches nothing — exactly when the entry must survive. + await user.type(search, "claude-fable-5-1") + expect(screen.getByText(MSGS.noModels)).toBeInTheDocument() + await user.click( + screen.getByRole("button", { name: MSGS.customModelEntry }) + ) + + const dialog = await screen.findByRole("dialog", { + name: MSGS.customModelTitle, + }) + await user.type( + within(dialog).getByLabelText(MSGS.customModelInputLabel), + "claude-fable-5-1" + ) + await user.click( + within(dialog).getByRole("button", { name: MSGS.customModelApply }) + ) + expect(onConfigOptionChange).toHaveBeenCalledWith( + "model", + "claude-fable-5-1" + ) + }) + + it("offers the entry in the collapsed cog panel too", async () => { + const user = userEvent.setup() + const onConfigOptionChange = vi.fn() + const { container } = renderInput({ + configOptions: [MODEL_OPTION], + onConfigOptionChange, + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + const settingsLabel = MSGS.agentSettings + await user.click(screen.getByRole("button", { name: settingsLabel })) + const popover = await screen.findByRole("dialog", { name: settingsLabel }) + await user.click( + within(popover).getByRole("button", { name: MSGS.customModelEntry }) + ) + + // Picking the entry closes the cog popover, like a selection would… + await waitFor(() => + expect(screen.queryByRole("dialog", { name: settingsLabel })).toBeNull() + ) + // …and hands over to the shared dialog. + expect( + await screen.findByRole("dialog", { name: MSGS.customModelTitle }) + ).toBeInTheDocument() + }) + + it("shows the raw current id when it is not among the options", async () => { + // An agent can track a model OUTSIDE its advertised options (a custom + // pick it adopted, a refusal fallback, a resumed allowlist-excluded + // model). Every trigger falls back to the raw id rather than a blank or + // a stale advertised label. + const user = userEvent.setup() + const rawCurrent: SessionConfigOptionInfo = { + id: "model", + name: "Model", + description: "Pick the model", + category: null, + kind: { + type: "select", + current_value: "claude-fable-5-1", + options: [ + { value: "default", name: "Default", description: null }, + { value: "opus", name: "Opus", description: null }, + ], + groups: [], + }, + } + const { container } = renderInput({ + configOptions: [rawCurrent], + onConfigOptionChange: vi.fn(), + }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + // The wide inline trigger names the raw id… + expect( + screen.getByRole("button", { name: "Model: claude-fable-5-1" }) + ).toBeInTheDocument() + + // …and so does the collapsed rail's summary row. + const settingsLabel = MSGS.agentSettings + await user.click(screen.getByRole("button", { name: settingsLabel })) + const popover = await screen.findByRole("dialog", { name: settingsLabel }) + expect( + within(popover).getByRole("button", { name: /claude-fable-5-1/ }) + ).toBeInTheDocument() + }) +}) + // The `/` list is the agent's own `availableCommands`, which only arrive with // the connection. The editor is typable throughout that wait, so a `/` typed // mid-connect opens the panel on a loading row instead of doing nothing. diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 605cdc8045..6a7841f5a6 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -81,6 +81,7 @@ import { InlineSessionConfigToggle, } from "@/components/chat/session-config-selector" import { ModelOptionPicker } from "@/components/chat/model-option-picker" +import { CustomModelIdDialog } from "@/components/chat/custom-model-id-dialog" import { SelectorTooltip } from "@/components/chat/selector-tooltip" import { SessionSelectorsPanel, @@ -399,6 +400,14 @@ export function MessageInput({ // pick closes it explicitly — matching the prior cog menu, which also closed // on every selection. const [collapsedSelectorsOpen, setCollapsedSelectorsOpen] = useState(false) + // Which config option the custom-model-id dialog is entering a value for + // (`null` = closed). Every model-picker surface — the wide dropdown, the + // searchable popover, and the collapsed cog panel — funnels here, and the + // submitted id goes through the exact same `onConfigOptionChange` path as + // picking an advertised option. + const [customModelConfigId, setCustomModelConfigId] = useState( + null + ) // Keep the collapsed settings popover open while dragging the (virtualized) // model list's native scrollbar — see `useScrollbarSafeDismiss`. const collapsedSelectorsGuard = useScrollbarSafeDismiss() @@ -1464,6 +1473,12 @@ export function MessageInput({ /> ) } + // Only the MODEL option gets the free-text "Use custom model ID..." + // escape hatch — a brand-new model is often live on the wire before + // the agent's curated list catches up. Other selects never do. + const onUseCustomModel = isModelConfigOption(option) + ? () => setCustomModelConfigId(option.id) + : undefined // Long model lists get the searchable + virtualized popover (a Radix // menu of hundreds of items is the scroll jank); every other option — // and short model lists — keep the lightweight inline dropdown. @@ -1477,6 +1492,7 @@ export function MessageInput({ onSelect={(configId, valueId) => onConfigOptionChange?.(configId, valueId) } + onUseCustomModel={onUseCustomModel} /> ) } @@ -1488,6 +1504,12 @@ export function MessageInput({ onSelect={(configId, valueId) => onConfigOptionChange?.(configId, valueId) } + customModelEntry={ + onUseCustomModel && { + label: t("customModelEntry"), + onOpen: onUseCustomModel, + } + } /> ) })} @@ -1595,6 +1617,14 @@ export function MessageInput({ empty: t("noModels"), }, }), + // Same escape hatch as the wide pickers: only the MODEL option + // offers free-text entry, routed through the shared dialog. + ...(isModelConfigOption(option) && { + customEntry: { + label: t("customModelEntry"), + onSelect: () => setCustomModelConfigId(option.id), + }, + }), }) } } @@ -2146,6 +2176,20 @@ export function MessageInput({ initialPath={defaultPath ?? undefined} /> )} + setCustomModelConfigId(null)} + onSubmit={(modelId) => { + // The typed id takes the exact path an advertised pick takes — + // optimistic apply, saved preference, `session/set_config_option` — + // so an agent that settles elsewhere or refuses is reported by the + // same rejection surfaces. + if (customModelConfigId) { + onConfigOptionChange?.(customModelConfigId, modelId) + } + setCustomModelConfigId(null) + }} + /> ) } diff --git a/src/components/chat/model-option-picker.tsx b/src/components/chat/model-option-picker.tsx index eee5b46829..76da0fbd91 100644 --- a/src/components/chat/model-option-picker.tsx +++ b/src/components/chat/model-option-picker.tsx @@ -21,6 +21,10 @@ interface ModelOptionPickerProps { * headerless group for a long flat list). */ groups: ModelOptionGroup[] onSelect: (configId: string, valueId: string) => void + /** When set, a pinned "Use custom model ID..." footer opens free-text entry. + * Sits below the list (not inside it) so it stays reachable even when a + * search leaves no matching rows — the very case a brand-new id hits. */ + onUseCustomModel?: () => void } // Wide-form model picker for LONG model lists: a trigger button opening a @@ -36,6 +40,7 @@ export function ModelOptionPicker({ option, groups, onSelect, + onUseCustomModel, }: ModelOptionPickerProps) { const t = useTranslations("Folder.chat.messageInput") const [open, setOpen] = useState(false) @@ -89,7 +94,7 @@ export function ModelOptionPicker({ // the popup opens upward and the list's own cap (`MAX_LIST_HEIGHT_REM`) is // a rem — at high zoom it outgrows the space above the trigger. The list // inside is flex-shrinkable, so it gives way to this cap. - className="max-h-(--radix-popover-content-available-height) w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden p-0" + className="flex max-h-(--radix-popover-content-available-height) w-[22rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden p-0" > + {onUseCustomModel && ( +
+ +
+ )} ) diff --git a/src/components/chat/session-config-selector.test.tsx b/src/components/chat/session-config-selector.test.tsx index 39e837bdba..ae8e2b390e 100644 --- a/src/components/chat/session-config-selector.test.tsx +++ b/src/components/chat/session-config-selector.test.tsx @@ -184,6 +184,47 @@ function autoApproveOption(current: boolean): SessionConfigOptionInfo { } } +describe("InlineSessionConfigSelector — custom model entry", () => { + afterEach(() => cleanup()) + + it("appends the entry row when provided, and fires its opener", async () => { + const user = userEvent.setup() + const onOpen = vi.fn() + const option = modelOption( + [ + { value: "default", name: "Default" }, + { value: "opus", name: "Opus" }, + ], + "default" + ) + render( + + ) + + await user.click(screen.getByRole("button", { name: "Model: Default" })) + await user.click( + await screen.findByRole("menuitem", { name: "Use custom model ID..." }) + ) + expect(onOpen).toHaveBeenCalledTimes(1) + }) + + it("renders no entry row when the prop is absent", async () => { + const user = userEvent.setup() + const option = modelOption([{ value: "opus", name: "Opus" }], "opus") + render() + + await user.click(screen.getByRole("button", { name: "Model: Opus" })) + expect( + await screen.findByRole("menuitemradio", { name: /Opus/ }) + ).toBeInTheDocument() + expect(screen.queryByRole("menuitem")).toBeNull() + }) +}) + describe("InlineSessionConfigToggle", () => { afterEach(() => cleanup()) diff --git a/src/components/chat/session-config-selector.tsx b/src/components/chat/session-config-selector.tsx index d7a10ec5ee..ad78fdb3a2 100644 --- a/src/components/chat/session-config-selector.tsx +++ b/src/components/chat/session-config-selector.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, @@ -28,12 +29,21 @@ interface SessionConfigSelectorProps { * means "no grouping" — fall back to server groups, else the flat list. */ derivedGroups?: ModelOptionGroup[] | null + /** + * When set, a trailing row (the model picker's "Use custom model ID...") + * opens free-text entry — passed only for the MODEL option (the caller + * gates on `isModelConfigOption`), never for other selects. The label rides + * along like the toggle's on/off labels, keeping this component + * translation-free. + */ + customModelEntry?: { label: string; onOpen: () => void } } export function InlineSessionConfigSelector({ option, onSelect, derivedGroups, + customModelEntry, }: SessionConfigSelectorProps) { if (option.kind.type !== "select") return null @@ -127,6 +137,17 @@ export function InlineSessionConfigSelector({ ))} + {customModelEntry && ( + <> + + customModelEntry.onOpen()} + > + {customModelEntry.label} + + + )} ) diff --git a/src/components/chat/session-selectors-panel.tsx b/src/components/chat/session-selectors-panel.tsx index 96a0011d22..816f450bbf 100644 --- a/src/components/chat/session-selectors-panel.tsx +++ b/src/components/chat/session-selectors-panel.tsx @@ -40,6 +40,10 @@ export interface SessionSelectorSetting { /** When set, the detail pane renders a searchable + virtualized list instead * of the plain button list — used for long model lists that otherwise jank. */ search?: SessionSelectorSearch + /** When set, a trailing row below the options opens free-text entry (the + * model picker's "Use custom model ID..."). The label is passed in so this + * panel stays translation-free, like the rest of its strings. */ + customEntry?: { label: string; onSelect: () => void } } interface SessionSelectorsPanelProps { @@ -125,7 +129,9 @@ export function SessionSelectorsPanel({ honest pattern as the left rail. */} {active.search ? ( // Long model lists: a searchable + virtualized list (its own scroller), - // so no surrounding `overflow-y-auto` wrapper here. + // so no surrounding `overflow-y-auto` wrapper here. The custom-entry + // row is pinned BELOW the list so it stays reachable even when a + // search leaves no matching rows — the very case a brand-new id hits.
+ {active.customEntry && ( +
+ +
+ )}
) : (
))} + {active.customEntry && ( +
+ +
+ )}
)} ) } + +// The free-text escape hatch under the option rows ("Use custom model ID..."). +// A plain + ) +} diff --git a/src/contexts/acp-connections-context.test.tsx b/src/contexts/acp-connections-context.test.tsx index 656fc85b93..58de0f56fd 100644 --- a/src/contexts/acp-connections-context.test.tsx +++ b/src/contexts/acp-connections-context.test.tsx @@ -2210,9 +2210,9 @@ describe("AcpConnectionsProvider Grok cross-agent-type model switch", () => { }) it("does not strand a verdict when the set fails outright", async () => { - // A failed `set_config_option` emits only a recoverable Error — no option - // snapshot at all. Nothing may linger to be charged against a later, - // unrelated update. + // A failed `set_config_option` re-emits the last adopted options (the + // revert) followed by a recoverable Error — no rejection verdict. Nothing + // may linger to be charged against a later, unrelated update. const handlers = await connectGrokOwner() emitAcpEvent(handlers, { seq: 1, @@ -2228,20 +2228,78 @@ describe("AcpConnectionsProvider Grok cross-agent-type model switch", () => { emitAcpEvent(handlers, { seq: 2, connection_id: "spawned-conn", + type: "session_config_options", + config_options: grokModelOptions("grok-4.5"), + }) + emitAcpEvent(handlers, { + seq: 3, + connection_id: "spawned-conn", type: "error", message: "Failed to set config option: boom", agent_type: "grok", code: null, }) emitAcpEvent(handlers, { - seq: 3, + seq: 4, connection_id: "spawned-conn", type: "session_config_options", config_options: grokModelOptions("grok-4.5"), }) + // The optimistic pick snapped back to the model actually in effect… + expect( + h.store!.getConnection(TAB)!.configOptions?.[0]?.kind.current_value + ).toBe("grok-4.5") + // …and none of those snapshots reads as a rejection verdict. expect(h.toastWarning).not.toHaveBeenCalled() }) + + it("drops a sibling option the agent no longer advertises after a model switch", async () => { + // A model switch rebuilds the agent's whole option list — the claude + // adapter seeds effort levels per model, and a model outside its metadata + // (a free-typed custom id) carries none, so its answer simply omits the + // effort option. The store must adopt each broadcast wholesale: a stale + // effort selector left behind would offer levels that silently no-op. + const handlers = await connectGrokOwner() + const withEffort: SessionConfigOptionInfo[] = [ + ...grokModelOptions("grok-4.5"), + { + id: "effort", + name: "Effort", + category: "thought_level", + kind: { + type: "select", + current_value: "default", + options: [ + { value: "default", name: "Default" }, + { value: "high", name: "High" }, + ], + groups: [], + }, + }, + ] + emitAcpEvent(handlers, { + seq: 1, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: withEffort, + }) + expect(h.store!.getConnection(TAB)!.configOptions).toHaveLength(2) + + // The agent adopts the custom id and answers with a list that has no + // effort option (and a current model value outside the advertised rows). + emitAcpEvent(handlers, { + seq: 2, + connection_id: "spawned-conn", + type: "session_config_options", + config_options: grokModelOptions("grok-4.5-fable"), + }) + + const options = h.store!.getConnection(TAB)!.configOptions! + expect(options).toHaveLength(1) + expect(options[0].id).toBe("model") + expect(options[0].kind.current_value).toBe("grok-4.5-fable") + }) }) describe("empty-turn error diagnostics", () => { diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index b292df9446..6a1608e201 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2853,6 +2853,12 @@ "searchModelAria": "البحث عن النماذج", "modelListLabel": "النماذج", "noModels": "لم يتم العثور على نماذج", + "customModelEntry": "استخدام معرّف نموذج مخصص...", + "customModelTitle": "معرّف نموذج مخصص", + "customModelDescription": "تشغيل نموذج لا تقدمه القائمة بعد. يُرسل المعرّف إلى الوكيل كما هو مكتوب تمامًا، والوكيل هو من يقرر ما إذا كان يعمل.", + "customModelInputLabel": "معرّف النموذج", + "customModelPlaceholder": "مثل claude-fable-5-1", + "customModelApply": "استخدام النموذج", "cancel": "إلغاء", "send": "إرسال", "forkAndSend": "تفريع وإرسال", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 57b70186a4..f6440e9a33 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2853,6 +2853,12 @@ "searchModelAria": "Modelle suchen", "modelListLabel": "Modelle", "noModels": "Keine Modelle gefunden", + "customModelEntry": "Eigene Modell-ID verwenden...", + "customModelTitle": "Eigene Modell-ID", + "customModelDescription": "Führt ein Modell aus, das die Liste noch nicht anbietet. Die ID wird genau wie eingegeben an den Agenten gesendet, und der Agent entscheidet, ob sie funktioniert.", + "customModelInputLabel": "Modell-ID", + "customModelPlaceholder": "z. B. claude-fable-5-1", + "customModelApply": "Modell verwenden", "cancel": "Abbrechen", "send": "Senden", "forkAndSend": "Fork & Senden", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 875c7361ae..6ecbe70b79 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2855,6 +2855,12 @@ "searchModelAria": "Search models", "modelListLabel": "Models", "noModels": "No models found", + "customModelEntry": "Use custom model ID...", + "customModelTitle": "Custom model ID", + "customModelDescription": "Run a model the list does not offer yet. The ID is sent to the agent exactly as typed, and the agent decides whether it works.", + "customModelInputLabel": "Model ID", + "customModelPlaceholder": "e.g. claude-fable-5-1", + "customModelApply": "Use model", "cancel": "Cancel", "send": "Send", "forkAndSend": "Fork & Send", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 77a742a896..b0b39b4037 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2853,6 +2853,12 @@ "searchModelAria": "Buscar modelos", "modelListLabel": "Modelos", "noModels": "No se encontraron modelos", + "customModelEntry": "Usar ID de modelo personalizado...", + "customModelTitle": "ID de modelo personalizado", + "customModelDescription": "Ejecuta un modelo que la lista aún no ofrece. El ID se envía al agente tal como se escribe, y el agente decide si funciona.", + "customModelInputLabel": "ID del modelo", + "customModelPlaceholder": "p. ej. claude-fable-5-1", + "customModelApply": "Usar modelo", "cancel": "Cancelar", "send": "Enviar", "forkAndSend": "Fork y Enviar", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index b4c432fd76..e9fae5fde0 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2853,6 +2853,12 @@ "searchModelAria": "Rechercher des modèles", "modelListLabel": "Modèles", "noModels": "Aucun modèle trouvé", + "customModelEntry": "Utiliser un ID de modèle personnalisé...", + "customModelTitle": "ID de modèle personnalisé", + "customModelDescription": "Exécute un modèle que la liste ne propose pas encore. L'ID est envoyé à l'agent tel que saisi, et l'agent décide s'il fonctionne.", + "customModelInputLabel": "ID du modèle", + "customModelPlaceholder": "p. ex. claude-fable-5-1", + "customModelApply": "Utiliser le modèle", "cancel": "Annuler", "send": "Envoyer", "forkAndSend": "Fork & Envoyer", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fe51a74131..a8174029cf 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2853,6 +2853,12 @@ "searchModelAria": "モデルを検索", "modelListLabel": "モデル", "noModels": "モデルが見つかりません", + "customModelEntry": "カスタムモデル ID を使用...", + "customModelTitle": "カスタムモデル ID", + "customModelDescription": "リストにまだないモデルを実行します。ID は入力どおりにエージェントへ送信され、使用できるかどうかはエージェントが判断します。", + "customModelInputLabel": "モデル ID", + "customModelPlaceholder": "例: claude-fable-5-1", + "customModelApply": "モデルを使用", "cancel": "キャンセル", "send": "送信", "forkAndSend": "フォークして送信", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 08a75a9cb4..160b126e82 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2853,6 +2853,12 @@ "searchModelAria": "모델 검색", "modelListLabel": "모델", "noModels": "모델을 찾을 수 없습니다", + "customModelEntry": "사용자 지정 모델 ID 사용...", + "customModelTitle": "사용자 지정 모델 ID", + "customModelDescription": "목록에 아직 없는 모델을 실행합니다. ID는 입력한 그대로 에이전트에 전송되며, 사용 가능 여부는 에이전트가 결정합니다.", + "customModelInputLabel": "모델 ID", + "customModelPlaceholder": "예: claude-fable-5-1", + "customModelApply": "모델 사용", "cancel": "취소", "send": "보내기", "forkAndSend": "포크 & 전송", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 8c27886a48..8002073b9e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2853,6 +2853,12 @@ "searchModelAria": "Buscar modelos", "modelListLabel": "Modelos", "noModels": "Nenhum modelo encontrado", + "customModelEntry": "Usar ID de modelo personalizado...", + "customModelTitle": "ID de modelo personalizado", + "customModelDescription": "Executa um modelo que a lista ainda não oferece. O ID é enviado ao agente exatamente como digitado, e o agente decide se funciona.", + "customModelInputLabel": "ID do modelo", + "customModelPlaceholder": "ex.: claude-fable-5-1", + "customModelApply": "Usar modelo", "cancel": "Cancelar", "send": "Enviar", "forkAndSend": "Fork & Enviar", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f7ecdcc22c..c916d479e6 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2855,6 +2855,12 @@ "searchModelAria": "搜索模型", "modelListLabel": "模型", "noModels": "未找到模型", + "customModelEntry": "使用自定义模型 ID...", + "customModelTitle": "自定义模型 ID", + "customModelDescription": "运行列表中尚未提供的模型。ID 将按输入原样发送给智能体,由智能体决定是否可用。", + "customModelInputLabel": "模型 ID", + "customModelPlaceholder": "例如 claude-fable-5-1", + "customModelApply": "使用模型", "cancel": "取消", "send": "发送", "forkAndSend": "分叉发送", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 9dd20a906c..ee561d1194 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2853,6 +2853,12 @@ "searchModelAria": "搜尋模型", "modelListLabel": "模型", "noModels": "找不到模型", + "customModelEntry": "使用自訂模型 ID...", + "customModelTitle": "自訂模型 ID", + "customModelDescription": "執行清單尚未提供的模型。ID 會依輸入原樣傳送給智能體,由智能體決定是否可用。", + "customModelInputLabel": "模型 ID", + "customModelPlaceholder": "例如 claude-fable-5-1", + "customModelApply": "使用模型", "cancel": "取消", "send": "傳送", "forkAndSend": "分叉發送",