From 8c40b073b8f59af2f7a1a0111b90c755f5ef1606 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:17:56 -0700 Subject: [PATCH 1/2] fix: model mentions name a model in the prompt instead of switching Picking a model from the @ menu switched the thread's provider and model. Mentions are meant to tell the agent which model to use, for example when delegating, so they now insert `model:/` into the prompt and leave the thread's model alone. Since nothing switches, mentions offer every ready provider's models, not only those the thread could switch to. The text has no `@` because the editor turns `@` tokens into file chips. Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadComposer.tsx | 25 +++------- .../features/threads/ThreadDetailScreen.tsx | 1 - .../threads/use-composer-command-menu.ts | 26 ++++++---- ...hatComposer.modelMentions.browser.test.tsx | 49 +++---------------- apps/web/src/components/chat/ChatComposer.tsx | 34 +++++-------- .../chat/composerModelMentions.test.ts | 7 +-- .../components/chat/composerModelMentions.ts | 13 +---- .../components/chat/useComposerMenuItems.ts | 4 +- .../src/composerModelMentions.test.ts | 10 +++- .../src/composerModelMentions.ts | 5 ++ 10 files changed, 61 insertions(+), 113 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 61bd16ea0831..1a0d0ab33971 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -56,8 +56,6 @@ import Animated, { import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; -import { updateThreadDraftModelSelection } from "../../state/use-thread-composer-state"; -import { withRememberedModelOptions } from "../../state/use-model-option-memory"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { getComposerDraftSnapshot, @@ -241,7 +239,6 @@ export interface ThreadComposerProps { ) => Promise; readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly canSwitchProvider: boolean; - readonly onUpdateModelSelection?: (modelSelection: ModelSelection) => void; readonly onUpdateInteractionMode?: (interactionMode: ProviderInteractionMode) => void; readonly onHeightChange?: (height: number) => void; readonly onEditorFocusChange?: (focused: boolean) => void; @@ -569,21 +566,15 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps offersUsageLimits: usageLimitsOffered, onUsageLimits: usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined, - modelOptions, + // Mentions name any model for the agent, so they aren't limited to models this thread can switch to. + modelOptions: buildModelOptions( + props.serverConfig, + currentModelSelection, + undefined, + props.selectedThread.projectId, + "composer", + ), modelProviders: props.serverConfig?.providers, - onSelectModel: - props.interactionLocked === true - ? undefined - : (option) => { - const selection = withRememberedModelOptions(option.selection); - if (props.onUpdateModelSelection) props.onUpdateModelSelection(selection); - else - updateThreadDraftModelSelection( - composerOwnerKey, - selection, - props.serverConfig?.providers ?? [], - ); - }, }); const voiceInput = useVoiceInputController({ ownerKey: composerDraftKey, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2a80b7d39d28..699359c71bac 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1641,7 +1641,6 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { onSendMessage={handleSendMessage} onShowUsageLimits={showUsageLimits} canSwitchProvider={props.canSwitchThreadProvider} - onUpdateModelSelection={props.onUpdateThreadModelSelection} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onHeightChange={handleComposerHeightChange} onEditorFocusChange={handleOwnedInputFocusChange} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 2c8805b44e08..3c55d466ad4c 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -51,7 +51,10 @@ import { resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; -import { rankComposerModelMentions } from "@t3tools/client-runtime/composerModelMentions"; +import { + formatComposerModelMention, + rankComposerModelMentions, +} from "@t3tools/client-runtime/composerModelMentions"; import { useAtomRefresh } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { useEffect, useState } from "react"; @@ -273,7 +276,6 @@ export function useComposerCommandMenu({ onUsageLimits, modelOptions, modelProviders, - onSelectModel, }: { readonly draftMessage: string; readonly ownerKey: string | null; @@ -294,10 +296,9 @@ export function useComposerCommandMenu({ readonly onChangeDraftMessage: (value: string) => void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; readonly onUsageLimits?: () => void; - /** Models an `@` mention can switch to; leave `onSelectModel` unset when switching isn't allowed. */ + /** Models an `@` mention can name in the prompt. */ readonly modelOptions?: ReadonlyArray; readonly modelProviders?: ReadonlyArray; - readonly onSelectModel?: (option: ModelOption) => void; }) { const [localSelection, setLocalSelection] = useState(() => ({ ownerKey, @@ -520,7 +521,7 @@ export function useComposerCommandMenu({ ? matchComposerEnvironmentItems({ environments, environmentId, query: trigger.query }) : []; const modelItems = - modelOptions && modelProviders && onSelectModel + modelOptions && modelProviders ? buildComposerModelItems(modelOptions, trigger.query, modelProviders) : []; return [ @@ -547,11 +548,16 @@ export function useComposerCommandMenu({ const onSelect = (item: ComposerCommandItem) => { if (!trigger) return; if (item.type === "model") { - if (trigger.kind !== "path" || !onSelectModel) return; - const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); - setSelection({ start: cleared.cursor, end: cleared.cursor }); - onChangeDraftMessage(cleared.text); - onSelectModel(item.option); + if (trigger.kind !== "path") return; + const { instanceId, model } = item.option.selection; + const result = replaceTextRange( + draftMessage, + trigger.rangeStart, + trigger.rangeEnd, + `${formatComposerModelMention(instanceId, model)} `, + ); + onChangeDraftMessage(result.text); + setSelection({ start: result.cursor, end: result.cursor }); return; } if (item.type === "environment") { diff --git a/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx b/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx index cc5c7d1cbf39..6c6529ef6868 100644 --- a/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx +++ b/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx @@ -5,12 +5,11 @@ import type { Editor } from "@tiptap/core"; import { createRef, type ReactNode } from "react"; import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; -import { act, fakeTimers, render } from "../../../test/render"; +import { act, render } from "../../../test/render"; import { useComposerDraftStore } from "../../composerDraftStore"; import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; import { makeThreadFixture } from "../../test-fixtures"; import { ChatComposer, type ChatComposerHandle, type ChatComposerProps } from "./ChatComposer"; -import { useComposerSelection } from "./useComposerSelection"; vi.mock("../../state/primaryEnvironment", async () => { const { Atom } = await import("effect/reactivity"); @@ -70,9 +69,6 @@ function setup() { slashCommands: [], skills: [], }; - const focusComposer = () => composerRef.current?.focusAtEnd(); - // ChatView schedules focusAtEnd after a provider selection. - const scheduleComposerFocus = vi.fn(() => window.requestAnimationFrame(focusComposer)); const noop = () => {}; const props: ChatComposerProps = { composerDraftTarget: target, @@ -178,8 +174,8 @@ function setup() { toggleInteractionMode: noop, handleRuntimeModeChange: noop, handleInteractionModeChange: noop, - focusComposer, - scheduleComposerFocus, + focusComposer: noop, + scheduleComposerFocus: noop, setThreadError: noop, onExpandImage: noop, onFileOpen: noop, @@ -187,49 +183,20 @@ function setup() { return { props, composerRef }; } -function ComposerWithSelection({ props }: { props: ChatComposerProps }) { - const selection = useComposerSelection({ - environmentId: props.environmentId, - composerDraftTarget: props.composerDraftTarget, - draftThread: null, - activeThread: props.activeThread, - activeRuntime: null, - activeProjectSettings: { settings: { newWorktreesStartFromOrigin: false } }, - settings: props.settings, - providerStatuses: props.providerStatuses, - lockedProvider: null, - supportsProviderSwitchingViaHandoff: false, - multipleModelSelections: null, - runtimeMode: props.runtimeMode, - interactionMode: props.interactionMode, - interactionModeEnabled: true, - isLocalDraftThread: false, - canOverrideServerThreadEnvMode: false, - setPendingServerThreadEnvModeEntry: () => {}, - setPendingServerThreadStartFromOriginByThreadId: () => {}, - scheduleComposerFocus: props.scheduleComposerFocus, - }); - return ; -} - -it("keeps typing at the removed model mention before the remaining text", async () => { +it("inserts the picked model as text without switching the thread's model", async () => { const { props, composerRef } = setup(); + const onProviderModelSelect = vi.fn(); await using screen = await render( - + , ); const element = screen.container.querySelector(".tiptap"); if (!element) throw new Error("Composer did not mount"); - const editor = element.editor; - using _timers = fakeTimers(); await act(() => composerRef.current?.focusAt(7)); await userEvent.keyboard("@gpt"); expect(props.promptRef.current).toBe("before @gpt after"); await userEvent.keyboard("{Enter}"); - await act(() => vi.advanceTimersByTime(32)); - expect(editor.getText()).toBe("before after"); - await expect.element(element).toHaveTextContent("before after"); - await userEvent.keyboard("X"); - await expect.element(element).toHaveTextContent("before X after"); + await expect.element(element).toHaveTextContent("before model:codex/gpt-5.4 after"); + expect(onProviderModelSelect).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d83363244a67..b16d6ad6e9ed 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -43,6 +43,7 @@ import { import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Schema from "effect/Schema"; +import { formatComposerModelMention } from "@t3tools/client-runtime/composerModelMentions"; import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; @@ -1169,12 +1170,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showSkillsInSlashMenu: settings.showSkillsInSlashMenu, questionAttachmentTarget, modelMentions: - questionAttachmentTarget === null && !isSendBusy && !providerCatalogPending + questionAttachmentTarget === null && !providerCatalogPending ? { instanceEntries: providerInstanceEntries, modelOptionsByInstance, - lockedProvider, - lockedContinuationGroupKey, isModelDisabled: (instanceId, model) => getModelDisabledReason(instanceId, model) !== null, } @@ -2059,27 +2058,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (item.type === "model") { if (trigger.kind !== "path") return; - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + const replacement = `${formatComposerModelMention(item.instanceId, item.model)} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), }); - if (!applied) return; - setComposerHighlightedItemId(null); - // With several models picked, a mention adds one instead of replacing them all. - if (multipleModelSelections !== null) { - if ( - !multipleModelSelections.some( - (selection) => - selection.instanceId === item.instanceId && selection.model === item.model, - ) - ) { - setMultipleModelSelections([ - ...multipleModelSelections, - createModelSelection(item.instanceId, item.model), - ]); - } - return; + if (applied) { + setComposerHighlightedItemId(null); } - onProviderModelSelect(item.instanceId, item.model, { focusComposer: false }); return; } if (item.type === "environment") { diff --git a/apps/web/src/components/chat/composerModelMentions.test.ts b/apps/web/src/components/chat/composerModelMentions.test.ts index 0befa3a37ca1..5374562a50c1 100644 --- a/apps/web/src/components/chat/composerModelMentions.test.ts +++ b/apps/web/src/components/chat/composerModelMentions.test.ts @@ -53,8 +53,6 @@ function match( return matchComposerModelItems({ instanceEntries: [traex, opencode, claude], modelOptionsByInstance, - lockedProvider: null, - lockedContinuationGroupKey: null, isModelDisabled: () => false, query, ...overrides, @@ -66,10 +64,7 @@ describe("matchComposerModelItems", () => { expect(match("gpt").toSorted()).toEqual(["opencode:traex/gpt-6.1", "traex:gpt-6.1"]); }); - it("leaves out providers a started thread can't switch to and disabled models", () => { - expect(match("opus", { lockedProvider: claude.driverKind })).toEqual([ - "claudeAgent:claude-opus-5-5", - ]); + it("leaves out disabled models", () => { expect( match("opus", { isModelDisabled: (instanceId) => instanceId === claude.instanceId }), ).toEqual(["opencode:anthropic/opus"]); diff --git a/apps/web/src/components/chat/composerModelMentions.ts b/apps/web/src/components/chat/composerModelMentions.ts index 1fd30a9d68df..47ee0042bbc7 100644 --- a/apps/web/src/components/chat/composerModelMentions.ts +++ b/apps/web/src/components/chat/composerModelMentions.ts @@ -1,5 +1,5 @@ import { rankComposerModelMentions } from "@t3tools/client-runtime/composerModelMentions"; -import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import type { ProviderInstanceId } from "@t3tools/contracts"; import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; @@ -14,7 +14,6 @@ type ComposerModelMentionProvider = Pick< | "displayName" | "acpRegistryAgentId" | "acpRegistryIconUrl" - | "continuationGroupKey" | "enabled" | "isAvailable" | "status" @@ -23,22 +22,12 @@ type ComposerModelMentionProvider = Pick< export function matchComposerModelItems(input: { readonly instanceEntries: ReadonlyArray; readonly modelOptionsByInstance: ReadonlyMap>; - readonly lockedProvider: ProviderDriverKind | null; - readonly lockedContinuationGroupKey: string | null | undefined; readonly isModelDisabled: (instanceId: ProviderInstanceId, model: string) => boolean; readonly query: string; }): ComposerModelItem[] { const candidates: Array<{ entry: ComposerModelMentionProvider; option: ModelEsque }> = []; for (const entry of input.instanceEntries) { if (!isProviderInstancePickerReady(entry)) continue; - if ( - input.lockedProvider !== null && - (entry.driverKind !== input.lockedProvider || - (input.lockedContinuationGroupKey && - entry.continuationGroupKey !== input.lockedContinuationGroupKey)) - ) { - continue; - } for (const option of input.modelOptionsByInstance.get(entry.instanceId) ?? []) { // The picker hides legacy models by default; skip them so they don't fill the result cap. if (option.isLegacy || option.isUnavailable) continue; diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 300698652b43..fc8c2b79c0c0 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -86,12 +86,10 @@ export function useComposerMenuItems({ readonly selectedProviderSlashCommands: ServerProvider["slashCommands"]; readonly showSkillsInSlashMenu: boolean; readonly questionAttachmentTarget: DraftId | null; - /** Null when the composer can't switch models, such as while answering an agent's question. */ + /** Null when model mentions are off, such as while answering an agent's question. */ readonly modelMentions: { readonly instanceEntries: ReadonlyArray; readonly modelOptionsByInstance: ReadonlyMap>; - readonly lockedProvider: ProviderDriverKind | null; - readonly lockedContinuationGroupKey: string | null | undefined; readonly isModelDisabled: (instanceId: ProviderInstanceId, model: string) => boolean; } | null; }) { diff --git a/packages/client-runtime/src/composerModelMentions.test.ts b/packages/client-runtime/src/composerModelMentions.test.ts index 26053108e663..74f69184322a 100644 --- a/packages/client-runtime/src/composerModelMentions.test.ts +++ b/packages/client-runtime/src/composerModelMentions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { rankComposerModelMentions } from "./composerModelMentions.ts"; +import { formatComposerModelMention, rankComposerModelMentions } from "./composerModelMentions.ts"; const models = [ { id: "traex:gpt", name: "GPT-6.1", driverKind: "codex", providerDisplayName: "Traex" }, @@ -51,3 +51,11 @@ describe("rankComposerModelMentions", () => { expect(rank("src")).toEqual([]); }); }); + +describe("formatComposerModelMention", () => { + it("names the provider instance and the model", () => { + expect(formatComposerModelMention("opencode", "anthropic/opus")).toBe( + "model:opencode/anthropic/opus", + ); + }); +}); diff --git a/packages/client-runtime/src/composerModelMentions.ts b/packages/client-runtime/src/composerModelMentions.ts index bc7227a572cf..619386ad873f 100644 --- a/packages/client-runtime/src/composerModelMentions.ts +++ b/packages/client-runtime/src/composerModelMentions.ts @@ -37,3 +37,8 @@ export function rankComposerModelMentions( } return []; } + +/** Prompt text naming a provider instance and model for the agent. No `@`, which the editor would turn into a file chip. */ +export function formatComposerModelMention(instanceId: string, model: string): string { + return `model:${instanceId}/${model}`; +} From dc46f6f4a78b42098e4db442689dd2bcd867873f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:03:03 -0700 Subject: [PATCH 2/2] fix: model mentions list every ready provider and stay out of thread model state Remove the obsolete model-switch gate from web mentions, including question answers, while keeping the model picker gate intact. Drop the dead mobile selection prop, resolve model insertion through the pure command resolver, and reuse unlocked model options when possible. Add the mobile text-and-cursor regression, assert the exact web prompt, and correct the composer guide to describe model references for delegation without changing the thread model. Implemented with GPT-6.1 via the Codex harness. Co-Authored-By: Claude Fable 5.1 --- .../features/threads/NewTaskDraftScreen.tsx | 3 --- .../src/features/threads/ThreadComposer.tsx | 20 +++++++++++-------- .../features/threads/ThreadDetailScreen.tsx | 2 -- .../threads/use-composer-command-menu.test.ts | 18 +++++++++++++++++ .../threads/use-composer-command-menu.ts | 15 ++------------ ...hatComposer.modelMentions.browser.test.tsx | 6 ++---- apps/web/src/components/chat/ChatComposer.tsx | 15 ++++++-------- .../chat/composerModelMentions.test.ts | 7 ------- .../components/chat/composerModelMentions.ts | 2 -- .../components/chat/useComposerMenuItems.ts | 3 +-- docs/user/composer.md | 10 +++++----- 11 files changed, 46 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 5bc7b9ab7a9a..d5b5ff3c1370 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -976,9 +976,6 @@ export function NewTaskDraftScreen(props: { onSendMessage={handleStart} onReconnectEnvironment={() => onReconnectEnvironment(selectedProject.environmentId)} canSwitchThreadProvider - onUpdateThreadModelSelection={(selection) => - flow.setSelectedModelKey(`${selection.instanceId}:${selection.model}`, selection.options) - } onUpdateThreadInteractionMode={flow.planModeEnabled ? flow.setInteractionMode : undefined} onRespondToApproval={async () => {}} onSelectUserInputOption={() => {}} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1a0d0ab33971..7c713508501b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -541,6 +541,17 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps props.selectedThread.projectId, "composer", ); + // Mentions name any model for the agent, so they aren't limited to models this thread can switch to. + const mentionModelOptions = + lockedProviderInstanceId === undefined + ? modelOptions + : buildModelOptions( + props.serverConfig, + currentModelSelection, + undefined, + props.selectedThread.projectId, + "composer", + ); const composerMenu = useComposerCommandMenu({ draftMessage: draftMessage, ownerKey: composerDraftKey, @@ -566,14 +577,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps offersUsageLimits: usageLimitsOffered, onUsageLimits: usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined, - // Mentions name any model for the agent, so they aren't limited to models this thread can switch to. - modelOptions: buildModelOptions( - props.serverConfig, - currentModelSelection, - undefined, - props.selectedThread.projectId, - "composer", - ), + modelOptions: mentionModelOptions, modelProviders: props.serverConfig?.providers, }); const voiceInput = useVoiceInputController({ diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 699359c71bac..65de3b83bcd3 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -41,7 +41,6 @@ import { deriveBottomControlInsets, type NativeLayoutMetrics } from "../../lib/r import type { EnvironmentId, MessageId, - ModelSelection, ProviderInteractionMode, RuntimeRequestId, ScopedThreadRef, @@ -286,7 +285,6 @@ export interface ThreadDetailScreenProps { ) => Promise; readonly onReconnectEnvironment: () => void; readonly canSwitchThreadProvider: boolean; - readonly onUpdateThreadModelSelection?: (modelSelection: ModelSelection) => void; readonly onUpdateThreadInteractionMode?: (interactionMode: ProviderInteractionMode) => void; readonly onRespondToApproval: ApprovalResponder; readonly onSelectUserInputOption: ( diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 24901c933332..14c807c6efee 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -164,6 +164,24 @@ describe("mobile model mentions", () => { }; }; + it("replaces a model mention with prompt text and places the cursor after it", () => { + const modelOption = option("codex:gpt-5.4"); + expect( + resolveComposerCommandSelection({ + draftMessage: "@gpt", + trigger: { rangeStart: 0, rangeEnd: 4 }, + item: { + id: `model:${modelOption.key}`, + type: "model", + option: modelOption, + label: modelOption.label, + description: modelOption.providerLabel, + }, + allowInteractionMode: true, + }), + ).toEqual({ text: "model:codex/gpt-5.4 ", cursor: 20, interactionMode: null }); + }); + it("offers current, visible models labelled with their provider", () => { const items = buildComposerModelItems( [ diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 3c55d466ad4c..081bf4a615d4 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -247,6 +247,8 @@ export function resolveComposerCommandSelection(input: { replacement = `/${item.command} `; } else if (item.type === "provider-slash-command") { replacement = `/${item.command.name} `; + } else if (item.type === "model") { + replacement = `${formatComposerModelMention(item.option.selection.instanceId, item.option.selection.model)} `; } return { ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement), @@ -547,19 +549,6 @@ export function useComposerCommandMenu({ const onSelect = (item: ComposerCommandItem) => { if (!trigger) return; - if (item.type === "model") { - if (trigger.kind !== "path") return; - const { instanceId, model } = item.option.selection; - const result = replaceTextRange( - draftMessage, - trigger.rangeStart, - trigger.rangeEnd, - `${formatComposerModelMention(instanceId, model)} `, - ); - onChangeDraftMessage(result.text); - setSelection({ start: result.cursor, end: result.cursor }); - return; - } if (item.type === "environment") { if (!ownerKey || trigger.kind !== "path") return; const record = environmentComposerContext(item.environmentId, item.label); diff --git a/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx b/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx index 6c6529ef6868..6b87038d99d9 100644 --- a/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx +++ b/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx @@ -185,10 +185,9 @@ function setup() { it("inserts the picked model as text without switching the thread's model", async () => { const { props, composerRef } = setup(); - const onProviderModelSelect = vi.fn(); await using screen = await render( - + , ); const element = screen.container.querySelector(".tiptap"); @@ -197,6 +196,5 @@ it("inserts the picked model as text without switching the thread's model", asyn await userEvent.keyboard("@gpt"); expect(props.promptRef.current).toBe("before @gpt after"); await userEvent.keyboard("{Enter}"); - await expect.element(element).toHaveTextContent("before model:codex/gpt-5.4 after"); - expect(onProviderModelSelect).not.toHaveBeenCalled(); + await expect.poll(() => props.promptRef.current).toBe("before model:codex/gpt-5.4 after"); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b16d6ad6e9ed..74d6f9e9fd91 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1169,15 +1169,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, questionAttachmentTarget, - modelMentions: - questionAttachmentTarget === null && !providerCatalogPending - ? { - instanceEntries: providerInstanceEntries, - modelOptionsByInstance, - isModelDisabled: (instanceId, model) => - getModelDisabledReason(instanceId, model) !== null, - } - : null, + modelMentions: !providerCatalogPending + ? { + instanceEntries: providerInstanceEntries, + modelOptionsByInstance, + } + : null, }); const composerMenuOpen = Boolean(composerTrigger); diff --git a/apps/web/src/components/chat/composerModelMentions.test.ts b/apps/web/src/components/chat/composerModelMentions.test.ts index 5374562a50c1..6f1f59998edc 100644 --- a/apps/web/src/components/chat/composerModelMentions.test.ts +++ b/apps/web/src/components/chat/composerModelMentions.test.ts @@ -53,7 +53,6 @@ function match( return matchComposerModelItems({ instanceEntries: [traex, opencode, claude], modelOptionsByInstance, - isModelDisabled: () => false, query, ...overrides, }).map((item) => `${item.instanceId}:${item.model}`); @@ -64,12 +63,6 @@ describe("matchComposerModelItems", () => { expect(match("gpt").toSorted()).toEqual(["opencode:traex/gpt-6.1", "traex:gpt-6.1"]); }); - it("leaves out disabled models", () => { - expect( - match("opus", { isModelDisabled: (instanceId) => instanceId === claude.instanceId }), - ).toEqual(["opencode:anthropic/opus"]); - }); - it("leaves out providers that are not ready", () => { expect( match("gpt", { instanceEntries: [{ ...traex, status: "error" as const }, opencode] }), diff --git a/apps/web/src/components/chat/composerModelMentions.ts b/apps/web/src/components/chat/composerModelMentions.ts index 47ee0042bbc7..770697c23358 100644 --- a/apps/web/src/components/chat/composerModelMentions.ts +++ b/apps/web/src/components/chat/composerModelMentions.ts @@ -22,7 +22,6 @@ type ComposerModelMentionProvider = Pick< export function matchComposerModelItems(input: { readonly instanceEntries: ReadonlyArray; readonly modelOptionsByInstance: ReadonlyMap>; - readonly isModelDisabled: (instanceId: ProviderInstanceId, model: string) => boolean; readonly query: string; }): ComposerModelItem[] { const candidates: Array<{ entry: ComposerModelMentionProvider; option: ModelEsque }> = []; @@ -31,7 +30,6 @@ export function matchComposerModelItems(input: { for (const option of input.modelOptionsByInstance.get(entry.instanceId) ?? []) { // The picker hides legacy models by default; skip them so they don't fill the result cap. if (option.isLegacy || option.isUnavailable) continue; - if (input.isModelDisabled(entry.instanceId, option.slug)) continue; candidates.push({ entry, option }); } } diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index fc8c2b79c0c0..84033f6b861f 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -86,11 +86,10 @@ export function useComposerMenuItems({ readonly selectedProviderSlashCommands: ServerProvider["slashCommands"]; readonly showSkillsInSlashMenu: boolean; readonly questionAttachmentTarget: DraftId | null; - /** Null when model mentions are off, such as while answering an agent's question. */ + /** Null while the provider catalog is still loading. */ readonly modelMentions: { readonly instanceEntries: ReadonlyArray; readonly modelOptionsByInstance: ReadonlyMap>; - readonly isModelDisabled: (instanceId: ProviderInstanceId, model: string) => boolean; } | null; }) { // Thread mentions rank by recency as of when the composer mounted. diff --git a/docs/user/composer.md b/docs/user/composer.md index 35a8425e80a4..0acf2ee23238 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -296,11 +296,11 @@ and your next message starts the agent again with the new setup. Provider commands must start the message to run. T3 Code commands such as `/model` and `/plan`, and skill mentions, work on any line. -Switch models by typing `@` followed by part of a model or provider name, such -as `@opus` or `@gpt`. Mentions can't contain spaces, so join words with `:`, `/` -or `-`, for example `@gpt:traex`. Choosing a result switches the model and -removes the mention from your text. On web and desktop, when you've selected -several models for a new thread, the mentioned model is added to them. +Type `@` followed by part of a model or provider name, such as `@opus` or `@gpt`, +to insert a reference naming that model for the agent. This is useful for +delegation, for example “delegate this to …”. It does not change the thread's +model. Mentions can't contain spaces, so join words with `:`, `/` or `-`, for +example `@gpt:traex`. Send `/compact` in an existing conversation to reduce context usage when the provider supports it. Web and desktop also offer compaction from the context meter.