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 10514b256ee80..00015a3318a61 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 @@ -183,6 +183,18 @@ describe("mobile model mentions", () => { }; }; + it("matches Claude models by their short name", () => { + const opus = option("claude:claude-opus", { + label: "Claude flagship", + shortName: "Opus", + providerDriver: "claudeAgent", + providerLabel: "Claude", + }); + expect(buildComposerModelItems([opus], "opus", [provider("claude")])).toEqual([ + expect.objectContaining({ option: opus }), + ]); + }); + it("replaces a model mention with prompt text and places the cursor after it", () => { const modelOption = option("codex:gpt-5.4"); expect( @@ -232,4 +244,17 @@ describe("mobile model mentions", () => { ] as const)("does not suggest a preserved selection whose provider is %s", (_, providers) => { expect(buildComposerModelItems([option("traex:gpt-6.1")], "gpt", providers)).toEqual([]); }); + + it("suggests models from a provider whose health probe only warns", () => { + const opus = option("claude:claude-opus", { + label: "Claude Opus", + providerDriver: "claudeAgent", + providerLabel: "Claude", + }); + expect( + buildComposerModelItems([opus], "opus", [ + provider("claude", { status: "warning", auth: { status: "unknown" } }), + ]), + ).toEqual([expect.objectContaining({ option: opus })]); + }); }); 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 b80cfd740f74a..27601281fddf3 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -212,7 +212,8 @@ export function buildComposerModelItems( .filter( (provider) => provider.installed && - provider.status === "ready" && + provider.status !== "error" && + provider.status !== "disabled" && provider.auth.status !== "unauthenticated" && provider.availability !== "unavailable", ) @@ -229,6 +230,7 @@ export function buildComposerModelItems( query, (option) => ({ name: option.label, + ...(option.shortName ? { shortName: option.shortName } : {}), ...(option.subtitle ? { subProvider: option.subtitle } : {}), driverKind: option.providerDriver, providerDisplayName: option.providerLabel, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 717eed3403365..c4d0287316537 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -929,6 +929,43 @@ describe("synchronized mobile model visibility", () => { }); }); +describe("mobile unavailable provider model options", () => { + it.each([ + ["signed out", { auth: { status: "unauthenticated" as const } }], + ["not checked yet", { installed: false }], + ])("keeps an enabled %s provider listed with unavailable models", (_state, probe) => { + const config = { + providers: [ + { + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: true, + installed: true, + auth: { status: "authenticated" as const }, + ...probe, + models: [ + { slug: "claude-opus", name: "Claude Opus", isCustom: false, capabilities: null }, + ], + }, + ], + } as unknown as ServerConfig; + + const options = buildModelOptions(config, null, undefined, null, "composer"); + + expect(options).toMatchObject([ + { providerKey: "claudeAgent", providerLabel: "Claude", isUnavailable: true }, + ]); + expect( + resolveNewTaskModelSelection({ + draftSelection: null, + projectDefaultSelection: null, + stickySelection: null, + modelOptions: options, + }), + ).toBeNull(); + }); +}); + describe("mobile account pool model options", () => { const memberId = ProviderInstanceId.make("claudeAgent_work"); const poolId = ProviderInstanceId.make("pool_claudeAgent"); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index e08d3640cfb9e..dcfdf1977c692 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -194,9 +194,12 @@ function modelOptionProviderContext( (provider) => matchesLockedProvider(provider) && isProviderEnabledForProject(config, provider, projectId) && - provider.installed && - provider.auth.status !== "unauthenticated" && - !(provider.driver === "antigravity" && provider.availability === "unavailable"), + // Antigravity's catalog only exists once it is set up; other providers stay + // listed as unavailable so a pending or failed probe cannot erase them. + (provider.driver !== "antigravity" || + (provider.installed && + provider.auth.status !== "unauthenticated" && + provider.availability !== "unavailable")), ), }; } @@ -390,6 +393,7 @@ export function buildModelOptions( continue; } const providerLabel = providerDisplayLabel(provider); + const providerUnavailable = !provider.installed || provider.auth.status === "unauthenticated"; const providerBadge = providerBadgeFor(provider.instanceId); const updateRequired = provider.updateRequiredModels?.length ? { driver: provider.driver, updateRequiredModels: provider.updateRequiredModels } @@ -420,6 +424,7 @@ export function buildModelOptions( ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), isHidden: !model.isCustom && hiddenModels.has(model.slug), canHide: !model.isCustom, + ...(providerUnavailable ? { isUnavailable: true } : {}), isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), diff --git a/apps/web/src/components/chat/composerModelMentions.test.ts b/apps/web/src/components/chat/composerModelMentions.test.ts index 6f1f59998edc7..bc61c303dad12 100644 --- a/apps/web/src/components/chat/composerModelMentions.test.ts +++ b/apps/web/src/components/chat/composerModelMentions.test.ts @@ -68,4 +68,10 @@ describe("matchComposerModelItems", () => { match("gpt", { instanceEntries: [{ ...traex, status: "error" as const }, opencode] }), ).toEqual(["opencode:traex/gpt-6.1"]); }); + + it("keeps providers whose health probe only warns", () => { + expect(match("opus", { instanceEntries: [{ ...claude, status: "warning" as const }] })).toEqual( + ["claudeAgent:claude-opus-5-5"], + ); + }); }); diff --git a/apps/web/src/components/chat/composerModelMentions.ts b/apps/web/src/components/chat/composerModelMentions.ts index 770697c23358f..ab7653a7b2a8c 100644 --- a/apps/web/src/components/chat/composerModelMentions.ts +++ b/apps/web/src/components/chat/composerModelMentions.ts @@ -1,7 +1,7 @@ import { rankComposerModelMentions } from "@t3tools/client-runtime/composerModelMentions"; import type { ProviderInstanceId } from "@t3tools/contracts"; -import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import type { ProviderInstanceEntry } from "../../providerInstances"; import type { ComposerCommandItem } from "./ComposerCommandMenu"; import { getDisplayModelName, getProviderRowLabel, type ModelEsque } from "./providerIconUtils"; @@ -26,7 +26,16 @@ export function matchComposerModelItems(input: { }): ComposerModelItem[] { const candidates: Array<{ entry: ComposerModelMentionProvider; option: ModelEsque }> = []; for (const entry of input.instanceEntries) { - if (!isProviderInstancePickerReady(entry)) continue; + // Mentions only name a model for the agent; a warning probe (which the server + // still delegates through) must not hide the provider the way an error does. + if ( + !entry.enabled || + !entry.isAvailable || + entry.status === "error" || + entry.status === "disabled" + ) { + 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/packages/shared/src/testing/providerEnv.ts b/packages/shared/src/testing/providerEnv.ts index ac07867c20473..7ff440879ca77 100644 --- a/packages/shared/src/testing/providerEnv.ts +++ b/packages/shared/src/testing/providerEnv.ts @@ -5,6 +5,8 @@ const PROVIDER_ENV_PREFIXES = ["ANTHROPIC_", "CLAUDE_", "CODEX_", "OPENAI_", "T3CODE_", "T3_"]; const KEPT_ENV = new Set([ + // Selects the mobile test platform; the Vite config and platform-branched tests must agree on it. + "T3_MOBILE_TEST_PLATFORM", // CI sets these so the transfer budget integration test writes its report for the job summary. "T3CODE_TRANSFER_BUDGET_REPORT_PATH", "T3CODE_TRANSFER_BUDGET_RESULT_PATH",