diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 0eb77a1534c1..a21fcf039aca 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -980,9 +980,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 4e6417232e03..554ee9f9ec77 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, @@ -244,7 +242,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; @@ -546,6 +543,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 mentionableEnvironments = useMentionableEnvironments(); const composerMenu = useComposerCommandMenu({ draftMessage: draftMessage, @@ -573,21 +581,8 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps offersUsageLimits: usageLimitsOffered, onUsageLimits: usageLimitsOffered && draftAttachments.length === 0 ? openUsageLimits : undefined, - modelOptions, + modelOptions: mentionModelOptions, 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 e8e2f0ae212d..6652fe5adb46 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: ( @@ -1637,7 +1635,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.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 3a302c2e0898..6a08333bf965 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"; @@ -244,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), @@ -274,7 +279,6 @@ export function useComposerCommandMenu({ onUsageLimits, modelOptions, modelProviders, - onSelectModel, }: { readonly draftMessage: string; readonly ownerKey: string | null; @@ -297,10 +301,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, @@ -525,7 +528,7 @@ export function useComposerCommandMenu({ ? matchComposerEnvironmentItems({ environments, environmentId, query: trigger.query }) : []; const modelItems = - modelOptions && modelProviders && onSelectModel + modelOptions && modelProviders ? buildComposerModelItems(modelOptions, trigger.query, modelProviders) : []; return [ @@ -551,14 +554,6 @@ 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); - 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 1493b53f250c..c9e81a06c252 100644 --- a/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx +++ b/apps/web/src/components/chat/ChatComposer.modelMentions.browser.test.tsx @@ -5,7 +5,7 @@ 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"; @@ -53,7 +53,9 @@ function setup() { stickyOptionsByModelByProvider: {}, stickyActiveProvider: null, }); - const thread = makeThreadFixture(); + const thread = makeThreadFixture({ + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1" }, + }); const target = { environmentId: thread.environmentId, threadId: thread.id }; useComposerDraftStore.getState().setPrompt(target, "before after"); const composerRef = createRef(); @@ -66,13 +68,13 @@ function setup() { status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", - models: [{ slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: {} }], + models: [ + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: {} }, + { slug: "gpt-6.1", name: "GPT-6.1", isCustom: false, capabilities: {} }, + ], 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, @@ -168,8 +170,8 @@ function setup() { toggleInteractionMode: noop, handleRuntimeModeChange: noop, handleInteractionModeChange: noop, - focusComposer, - scheduleComposerFocus, + focusComposer: noop, + scheduleComposerFocus: noop, setThreadError: noop, onExpandImage: noop, onFileOpen: noop, @@ -202,7 +204,7 @@ function ComposerWithSelection({ props }: { props: ChatComposerProps }) { 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(); await using screen = await render( @@ -211,15 +213,16 @@ it("keeps typing at the removed model mention before the remaining text", async ); 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 expect.poll(() => props.promptRef.current).toBe("before model:codex/gpt-5.4 after"); await userEvent.keyboard("X"); - await expect.element(element).toHaveTextContent("before X after"); + await expect.element(element).toHaveTextContent("before model:codex/gpt-5.4 Xafter"); + expect( + useComposerDraftStore.getState().getComposerDraft(props.composerDraftTarget) + ?.modelSelectionByProvider, + ).toEqual({}); + expect(useComposerDraftStore.getState().stickyActiveProvider).toBeNull(); }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b6df77b52936..8d47ad18218c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -46,6 +46,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"; @@ -1134,17 +1135,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, questionAttachmentTarget, - modelMentions: - questionAttachmentTarget === null && !isSendBusy && !providerCatalogPending - ? { - instanceEntries: providerInstanceEntries, - modelOptionsByInstance, - lockedProvider, - lockedContinuationGroupKey, - isModelDisabled: (instanceId, model) => - getModelDisabledReason(instanceId, model) !== null, - } - : null, + modelMentions: !providerCatalogPending + ? { + instanceEntries: providerInstanceEntries, + modelOptionsByInstance, + } + : null, }); const composerMenuOpen = Boolean(composerTrigger); @@ -2000,27 +1996,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..6f1f59998edc 100644 --- a/apps/web/src/components/chat/composerModelMentions.test.ts +++ b/apps/web/src/components/chat/composerModelMentions.test.ts @@ -53,9 +53,6 @@ function match( return matchComposerModelItems({ instanceEntries: [traex, opencode, claude], modelOptionsByInstance, - lockedProvider: null, - lockedContinuationGroupKey: null, - isModelDisabled: () => false, query, ...overrides, }).map((item) => `${item.instanceId}:${item.model}`); @@ -66,15 +63,6 @@ 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", - ]); - 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 1fd30a9d68df..770697c23358 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,26 +22,14 @@ 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; - 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 f6b48d073a93..f7f4cb9a65c8 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -91,13 +91,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 while the provider catalog is still loading. */ 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; }) { // Thread mentions rank by recency as of when the composer mounted. diff --git a/docs/user/composer.md b/docs/user/composer.md index c41cf74c76b8..d3cbd946a1b4 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. 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}`; +}