From 30f89ce2cce1ac219f5214c3d2a7ecdbc2562cb2 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 09:08:43 +0000 Subject: [PATCH 01/24] feat: shorten model names by dropping redundant family prefixes The provider registry now derives shortName for every reported model, including custom ones: the family prefix (Claude, GPT-, Gemini, Muse, Grok) is dropped on the family's own provider, and elsewhere only when the rest still names a model. Names never shrink to a bare version. Mobile's composer model control shows the short name; the web picker trigger's tooltip now shows the full name. Co-Authored-By: Claude Opus 5.5 (Claude Code) --- .../src/components/ComposerModelControl.tsx | 2 +- apps/mobile/src/lib/modelOptions.ts | 3 + .../src/provider/ProviderRegistry.test.ts | 7 +- apps/server/src/provider/ProviderRegistry.ts | 14 +-- .../src/provider/modelShortName.test.ts | 97 +++++++++++++++++++ apps/server/src/provider/modelShortName.ts | 58 +++++++++++ .../components/chat/ProviderModelPicker.tsx | 8 +- 7 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/provider/modelShortName.test.ts create mode 100644 apps/server/src/provider/modelShortName.ts diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 431cf61b1ec62..e422cfabd4591 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -70,7 +70,7 @@ export function ComposerModelControl(props: { /> ) } - label={label} + label={props.modelOption?.shortName ?? label} iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index c36c63a4f1457..070a95733a7e3 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -20,6 +20,7 @@ import { export type ModelOption = { readonly key: string; readonly label: string; + readonly shortName?: string; readonly subtitle: string; readonly providerKey: string; readonly providerLabel: string; @@ -322,6 +323,7 @@ export function buildModelOptions( options.set(key, { key, label: model.name, + ...(model.shortName ? { shortName: model.shortName } : {}), subtitle: model.subProvider ?? "", providerKey: provider.instanceId, providerLabel, @@ -387,6 +389,7 @@ export function buildModelOptions( options.set(key, { key, label: model?.name ?? fallbackModelSelection.model, + ...(model?.shortName ? { shortName: model.shortName } : {}), subtitle: model?.subProvider ?? "", providerKey: fallbackModelSelection.instanceId, providerLabel, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 1c47a2f31c3fe..dc8dd66ef3b7d 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -920,7 +920,8 @@ it.layer( subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), }, ); - const retainedModels = [defaultModel, customModel, currentModel]; + const presentedCurrentModel = { ...currentModel, shortName: "6.1-Sol" }; + const retainedModels = [defaultModel, customModel, presentedCurrentModel]; for (const restarted of [false, true]) { yield* Effect.gen(function* () { @@ -929,13 +930,13 @@ it.layer( (yield* registry.getProviders)[0]?.models, restarted ? retainedModels - : [defaultModel, customModel, currentModel, removedModel], + : [defaultModel, customModel, presentedCurrentModel, removedModel], ); yield* registry.refreshInstance(instance.instanceId); assert.deepStrictEqual( (yield* readProviderStatusCache(filePath))?.models, - restarted ? retainedModels : refreshedProvider.models, + restarted ? retainedModels : [defaultModel, presentedCurrentModel, customModel], ); yield* Ref.set(nextProvider, failedProvider); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 78b11306c5b6d..91e9fc9cc4b39 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -25,6 +25,7 @@ import * as Semaphore from "effect/Semaphore"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ModelManifest from "./ModelManifest.ts"; import { applyProviderCompatibility } from "./providerCompatibility.ts"; +import { withModelShortNames } from "./modelShortName.ts"; import * as ServerConfig from "../config.ts"; import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import { @@ -399,17 +400,14 @@ export const layer = Layer.effect( ), ); const initialManifest = yield* manifestService.current; - const classifyCompatibility = ( - provider: ServerProvider, - manifest: ModelManifest.ModelManifestData, - ) => + const presentProvider = (provider: ServerProvider, manifest: ModelManifest.ModelManifestData) => applyProviderCompatibility( - provider, + withModelShortNames(provider), manifest.compatibility, ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, ); const providersRef = yield* Ref.make>( - cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), + cachedProviders.map((provider) => presentProvider(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap>> @@ -495,9 +493,7 @@ export const layer = Layer.effect( } const providers = orderProviderSnapshots( - [...mergedProviders.values()].map((provider) => - classifyCompatibility(provider, manifest), - ), + [...mergedProviders.values()].map((provider) => presentProvider(provider, manifest)), ); const providersToPersist = providers.filter((provider) => updatedKeys.has(snapshotInstanceKey(provider)), diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts new file mode 100644 index 0000000000000..3719108cdf707 --- /dev/null +++ b/apps/server/src/provider/modelShortName.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { modelShortName, withModelShortNames } from "./modelShortName.ts"; + +describe("modelShortName", () => { + it.each([ + ["Claude Opus 5.5", "claudeAgent", "Opus 5.5"], + ["GPT-6.1 Sol", "codex", "6.1 Sol"], + ["GPT-6.1-Sol", "codex", "6.1-Sol"], + ["GPT-5.4 Mini", "codex", "5.4 Mini"], + ["Grok 4 Pro", "grok", "4 Pro"], + ["Gemini 3.8 Flash", "antigravity", "3.8 Flash"], + ["Muse Spark 1.3 Contributor", "muse", "Spark 1.3 Contributor"], + ] as const)("strips %s on its native provider", (name, driver, shortName) => { + expect(modelShortName(name, driver)).toBe(shortName); + }); + + it.each([ + ["GPT-6.1 Sol", "opencode", "6.1 Sol"], + ["Claude Opus 5.5", "pi", "Opus 5.5"], + ["Gemini 4 Argon Contributor", "pi", "4 Argon Contributor"], + ] as const)( + "strips %s elsewhere when the rest still names a model", + (name, driver, shortName) => { + expect(modelShortName(name, driver)).toBe(shortName); + }, + ); + + it.each([ + ["Grok 4 Pro", "opencode"], + ["Gemini 3.8 Flash", "cursor"], + ["GPT-5.4 Mini", "pi"], + ["Gemini 3.1 Pro (High)", "opencode"], + ] as const)("keeps %s whole off its native provider when only a tier remains", (name, driver) => { + expect(modelShortName(name, driver)).toBeUndefined(); + }); + + it.each([ + ["Grok 5", "grok"], + ["Grok 5", "opencode"], + ["GPT-5.5", "codex"], + ["Gemini 3 (High)", "antigravity"], + ] as const)("never reduces %s on %s to a bare version", (name, driver) => { + expect(modelShortName(name, driver)).toBeUndefined(); + }); + + it("keeps parenthetical suffixes", () => { + expect(modelShortName("Gemini 3.8 Flash (High)", "antigravity")).toBe("3.8 Flash (High)"); + }); + + it("leaves names outside the family table alone", () => { + expect(modelShortName("Big Pickle", "opencode")).toBeUndefined(); + expect(modelShortName("gpt-5.5", "opencode")).toBeUndefined(); + }); +}); + +describe("withModelShortNames", () => { + const model = (input: Partial & { name: string }): ServerProviderModel => ({ + slug: input.name.toLowerCase().replaceAll(" ", "-"), + isCustom: false, + capabilities: null, + ...input, + }); + const provider = (models: ReadonlyArray): ServerProvider => ({ + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claudeAgent"), + enabled: true, + installed: true, + version: "3.0.0", + status: "ready", + checkedAt: "2026-10-23T00:00:00Z", + auth: { status: "authenticated" }, + models, + skills: [], + slashCommands: [], + }); + + it("fills custom models and keeps an explicit shortName", () => { + const result = withModelShortNames( + provider([ + model({ name: "Claude Opus 5.5", shortName: "Opus" }), + model({ name: "Claude Sonnet 6", isCustom: true }), + ]), + ); + expect(result.models.map((entry) => entry.shortName)).toEqual(["Opus", "Sonnet 6"]); + }); + + it("returns the same snapshot when nothing shortens", () => { + const snapshot = provider([model({ name: "Claude 5" })]); + expect(withModelShortNames(snapshot)).toBe(snapshot); + }); +}); diff --git a/apps/server/src/provider/modelShortName.ts b/apps/server/src/provider/modelShortName.ts new file mode 100644 index 0000000000000..64740cf8e21d9 --- /dev/null +++ b/apps/server/src/provider/modelShortName.ts @@ -0,0 +1,58 @@ +import type { ServerProvider } from "@t3tools/contracts"; + +const MODEL_FAMILIES: ReadonlyArray<{ readonly prefix: string; readonly nativeDriver: string }> = [ + { prefix: "Claude ", nativeDriver: "claudeAgent" }, + { prefix: "GPT-", nativeDriver: "codex" }, + { prefix: "Gemini ", nativeDriver: "antigravity" }, + { prefix: "Muse ", nativeDriver: "muse" }, + { prefix: "Grok ", nativeDriver: "grok" }, +]; + +// Words that rank a model within its family rather than name one. Off the +// family's native provider there is no icon saying which family "4 Pro" belongs to. +const TIER_WORDS = new Set([ + "pro", + "flash", + "mini", + "max", + "lite", + "nano", + "ultra", + "plus", + "fast", + "turbo", + "preview", + "latest", + "thinking", + "reasoning", +]); + +const VERSION = /^v?\d+(?:\.\d+)*$/i; + +export function modelShortName(name: string, driver: string): string | undefined { + const family = MODEL_FAMILIES.find((candidate) => name.startsWith(candidate.prefix)); + if (!family) return undefined; + const rest = name.slice(family.prefix.length).trim(); + const words = rest + .replace(/\(.*?\)/g, " ") + .split(/[\s-]+/) + .filter(Boolean); + const named = words.filter((word) => !VERSION.test(word)); + if (named.length === 0) return undefined; + if (driver !== family.nativeDriver && named.every((word) => TIER_WORDS.has(word.toLowerCase()))) { + return undefined; + } + return rest; +} + +export function withModelShortNames(provider: ServerProvider): ServerProvider { + let changed = false; + const models = provider.models.map((model) => { + if (model.shortName) return model; + const shortName = modelShortName(model.name, provider.driver); + if (!shortName) return model; + changed = true; + return { ...model, shortName }; + }); + return changed ? { ...provider, models } : provider; +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e836a92f58714..48c4e32cc0b3d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -13,11 +13,7 @@ import { cn } from "~/lib/utils"; import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; import { ChatGptSharingControl } from "./ChatGptSharingControl"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; -import { - ModelEsque, - getTriggerDisplayModelLabel, - getTriggerDisplayModelName, -} from "./providerIconUtils"; +import { ModelEsque, getDisplayModelName, getTriggerDisplayModelName } from "./providerIconUtils"; import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, @@ -79,7 +75,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ? "Choose model" : props.model || "Choose model"; const triggerLabel = selectedModel - ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` + ? `${getDisplayModelName(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); From cbc4d8747ffcab7b8e3f62054f8ac9d4c1d3521d Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 09:16:14 +0000 Subject: [PATCH 02/24] fix: keep derived model short names out of the provider cache The registry now stores and persists snapshots without computed short names and derives them at its public API, so cached and retained models never carry a value from an older rule. Explicit manifest short names still win. The web multi-model tooltip and the context-window popover show full model names. Co-Authored-By: Claude Opus 5.5 (Claude Code) --- .../src/provider/ProviderRegistry.test.ts | 9 +-- apps/server/src/provider/ProviderRegistry.ts | 43 ++++++++++---- .../src/provider/modelShortName.test.ts | 56 +++---------------- .../chat/ContextWindowMeter.logic.test.ts | 2 +- .../chat/ContextWindowMeter.logic.ts | 4 +- .../components/chat/ProviderModelPicker.tsx | 8 +-- 6 files changed, 51 insertions(+), 71 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index dc8dd66ef3b7d..bdbef871c1ba6 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -920,8 +920,9 @@ it.layer( subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), }, ); + const retainedModels = [defaultModel, customModel, currentModel]; const presentedCurrentModel = { ...currentModel, shortName: "6.1-Sol" }; - const retainedModels = [defaultModel, customModel, presentedCurrentModel]; + const presentedRetainedModels = [defaultModel, customModel, presentedCurrentModel]; for (const restarted of [false, true]) { yield* Effect.gen(function* () { @@ -929,19 +930,19 @@ it.layer( assert.deepStrictEqual( (yield* registry.getProviders)[0]?.models, restarted - ? retainedModels + ? presentedRetainedModels : [defaultModel, customModel, presentedCurrentModel, removedModel], ); yield* registry.refreshInstance(instance.instanceId); assert.deepStrictEqual( (yield* readProviderStatusCache(filePath))?.models, - restarted ? retainedModels : [defaultModel, presentedCurrentModel, customModel], + restarted ? retainedModels : refreshedProvider.models, ); yield* Ref.set(nextProvider, failedProvider); const afterFailure = yield* registry.refreshInstance(instance.instanceId); - assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); + assert.deepStrictEqual(afterFailure[0]?.models, presentedRetainedModels); assert.deepStrictEqual( (yield* readProviderStatusCache(filePath))?.models, retainedModels, diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 91e9fc9cc4b39..8abd38a9075b1 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -400,14 +400,17 @@ export const layer = Layer.effect( ), ); const initialManifest = yield* manifestService.current; - const presentProvider = (provider: ServerProvider, manifest: ModelManifest.ModelManifestData) => + const classifyCompatibility = ( + provider: ServerProvider, + manifest: ModelManifest.ModelManifestData, + ) => applyProviderCompatibility( - withModelShortNames(provider), + provider, manifest.compatibility, ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, ); const providersRef = yield* Ref.make>( - cachedProviders.map((provider) => presentProvider(provider, initialManifest)), + cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap>> @@ -493,7 +496,9 @@ export const layer = Layer.effect( } const providers = orderProviderSnapshots( - [...mergedProviders.values()].map((provider) => presentProvider(provider, manifest)), + [...mergedProviders.values()].map((provider) => + classifyCompatibility(provider, manifest), + ), ); const providersToPersist = providers.filter((provider) => updatedKeys.has(snapshotInstanceKey(provider)), @@ -998,19 +1003,35 @@ export const layer = Layer.effect( return yield* Fiber.join(claim.scan); }); + // Derived short names stay out of the registry state so cached and retained + // models never carry a value computed by an older rule. + const presentProviders = (providers: ReadonlyArray) => + providers.map(withModelShortNames); + return { - getProviders: Ref.get(providersRef), + getProviders: Ref.get(providersRef).pipe(Effect.map(presentProviders)), refresh: (provider?: ProviderDriverKind) => - refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), + refresh(provider).pipe( + Effect.catchCause(recoverRefreshFailure), + Effect.map(presentProviders), + ), refreshInstance: (instanceId: ProviderInstanceId) => - refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstance(instanceId).pipe( + Effect.catchCause(recoverRefreshFailure), + Effect.map(presentProviders), + ), refreshWorkspaceSnapshot: (input) => - refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), - refreshAfterUpdate, + refreshWorkspaceSnapshot(input).pipe( + Effect.catchCause(recoverRefreshFailure), + Effect.map(presentProviders), + ), + refreshAfterUpdate: (provider, selectedInstanceId) => + refreshAfterUpdate(provider, selectedInstanceId).pipe(Effect.map(presentProviders)), getProviderMaintenanceCapabilitiesForInstance, - setProviderMaintenanceActionState, + setProviderMaintenanceActionState: (input) => + setProviderMaintenanceActionState(input).pipe(Effect.map(presentProviders)), get streamChanges() { - return Stream.fromPubSub(changesPubSub); + return Stream.fromPubSub(changesPubSub).pipe(Stream.map(presentProviders)); }, } satisfies ProviderRegistry["Service"]; }), diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts index 3719108cdf707..142360b4d19af 100644 --- a/apps/server/src/provider/modelShortName.test.ts +++ b/apps/server/src/provider/modelShortName.test.ts @@ -10,52 +10,15 @@ import { modelShortName, withModelShortNames } from "./modelShortName.ts"; describe("modelShortName", () => { it.each([ ["Claude Opus 5.5", "claudeAgent", "Opus 5.5"], - ["GPT-6.1 Sol", "codex", "6.1 Sol"], - ["GPT-6.1-Sol", "codex", "6.1-Sol"], ["GPT-5.4 Mini", "codex", "5.4 Mini"], - ["Grok 4 Pro", "grok", "4 Pro"], - ["Gemini 3.8 Flash", "antigravity", "3.8 Flash"], - ["Muse Spark 1.3 Contributor", "muse", "Spark 1.3 Contributor"], - ] as const)("strips %s on its native provider", (name, driver, shortName) => { - expect(modelShortName(name, driver)).toBe(shortName); - }); - - it.each([ ["GPT-6.1 Sol", "opencode", "6.1 Sol"], - ["Claude Opus 5.5", "pi", "Opus 5.5"], - ["Gemini 4 Argon Contributor", "pi", "4 Argon Contributor"], - ] as const)( - "strips %s elsewhere when the rest still names a model", - (name, driver, shortName) => { - expect(modelShortName(name, driver)).toBe(shortName); - }, - ); - - it.each([ - ["Grok 4 Pro", "opencode"], - ["Gemini 3.8 Flash", "cursor"], - ["GPT-5.4 Mini", "pi"], - ["Gemini 3.1 Pro (High)", "opencode"], - ] as const)("keeps %s whole off its native provider when only a tier remains", (name, driver) => { - expect(modelShortName(name, driver)).toBeUndefined(); - }); - - it.each([ - ["Grok 5", "grok"], - ["Grok 5", "opencode"], - ["GPT-5.5", "codex"], - ["Gemini 3 (High)", "antigravity"], - ] as const)("never reduces %s on %s to a bare version", (name, driver) => { - expect(modelShortName(name, driver)).toBeUndefined(); - }); - - it("keeps parenthetical suffixes", () => { - expect(modelShortName("Gemini 3.8 Flash (High)", "antigravity")).toBe("3.8 Flash (High)"); - }); - - it("leaves names outside the family table alone", () => { - expect(modelShortName("Big Pickle", "opencode")).toBeUndefined(); - expect(modelShortName("gpt-5.5", "opencode")).toBeUndefined(); + ["GPT-5.4 Mini", "pi", undefined], + ["GPT-5.5", "codex", undefined], + ["Gemini 3 (High)", "antigravity", undefined], + ["Gemini 3.8 Flash (High)", "antigravity", "3.8 Flash (High)"], + ["gpt-5.5", "opencode", undefined], + ] as const)("shortens %s on %s to %s", (name, driver, expected) => { + expect(modelShortName(name, driver)).toBe(expected); }); }); @@ -89,9 +52,4 @@ describe("withModelShortNames", () => { ); expect(result.models.map((entry) => entry.shortName)).toEqual(["Opus", "Sonnet 6"]); }); - - it("returns the same snapshot when nothing shortens", () => { - const snapshot = provider([model({ name: "Claude 5" })]); - expect(withModelShortNames(snapshot)).toBe(snapshot); - }); }); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index 670ccd541bdbd..e8d08570707bf 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -99,7 +99,7 @@ describe("resolveContextWindowModelDisplayName", () => { }, modelOptionsByInstance, ), - ).toBe("5.6 Sol"); + ).toBe("GPT-5.6 Sol"); }); }); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index c6bcfba0b00a3..5a0911a10a05f 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -8,7 +8,7 @@ import { resolveSelectableProviderInstanceEntry, type ProviderInstanceEntry, } from "../../providerInstances"; -import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; +import { getDisplayModelName, type ModelEsque } from "./providerIconUtils"; // Fallback TTL: Claude's 1h cache plus slack. const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000; @@ -96,7 +96,7 @@ export function resolveContextWindowModelDisplayName( .get(selection.instanceId) ?.find((model) => model.slug === selection.model); - return selectedModel ? getTriggerDisplayModelName(selectedModel) : selection.model; + return selectedModel ? getDisplayModelName(selectedModel) : selection.model; } export function formatContextWindowCompactionMessage( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 48c4e32cc0b3d..1d6fcbb00826e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -153,12 +153,12 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: selection.model, options: props.modelOptionsByInstance.get(selection.instanceId) ?? [], }); + const unavailable = model?.isUnavailable ? " (Unavailable)" : ""; return { ...selection, entry, - label: model - ? `${getTriggerDisplayModelName(model)}${model.isUnavailable ? " (Unavailable)" : ""}` - : selection.model, + label: model ? `${getTriggerDisplayModelName(model)}${unavailable}` : selection.model, + fullLabel: model ? `${getDisplayModelName(model)}${unavailable}` : selection.model, }; }); const multipleLabel = selectedEntries @@ -170,7 +170,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { .join(", ")}${selectedEntries.length > 2 ? `, ${selectedEntries.length - 2} more` : ""}` : undefined; const allModelNames = selectedEntries - ? selectedEntries.map((selection) => selection.label).join(", ") || "Choose models" + ? selectedEntries.map((selection) => selection.fullLabel).join(", ") || "Choose models" : undefined; const triggerTooltipContent = shortcutLabel ? `${props.triggerLabel ?? allModelNames ?? triggerLabel} · ${shortcutLabel}` From 019ecb67af166ba27744a733f50633a2ba0fc8c4 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 18:25:15 +0000 Subject: [PATCH 03/24] fix: space hyphenated short names and keep Grok Build whole --- apps/server/src/provider/ProviderRegistry.test.ts | 2 +- apps/server/src/provider/modelShortName.test.ts | 2 ++ apps/server/src/provider/modelShortName.ts | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index bdbef871c1ba6..fc08ca8aeff62 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -921,7 +921,7 @@ it.layer( }, ); const retainedModels = [defaultModel, customModel, currentModel]; - const presentedCurrentModel = { ...currentModel, shortName: "6.1-Sol" }; + const presentedCurrentModel = { ...currentModel, shortName: "6.1 Sol" }; const presentedRetainedModels = [defaultModel, customModel, presentedCurrentModel]; for (const restarted of [false, true]) { diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts index 142360b4d19af..3d36b9df41e59 100644 --- a/apps/server/src/provider/modelShortName.test.ts +++ b/apps/server/src/provider/modelShortName.test.ts @@ -12,6 +12,8 @@ describe("modelShortName", () => { ["Claude Opus 5.5", "claudeAgent", "Opus 5.5"], ["GPT-5.4 Mini", "codex", "5.4 Mini"], ["GPT-6.1 Sol", "opencode", "6.1 Sol"], + ["GPT-6.1-Sol", "opencode", "6.1 Sol"], + ["Grok Build", "grok", undefined], ["GPT-5.4 Mini", "pi", undefined], ["GPT-5.5", "codex", undefined], ["Gemini 3 (High)", "antigravity", undefined], diff --git a/apps/server/src/provider/modelShortName.ts b/apps/server/src/provider/modelShortName.ts index 64740cf8e21d9..2ca07b6a3fc1d 100644 --- a/apps/server/src/provider/modelShortName.ts +++ b/apps/server/src/provider/modelShortName.ts @@ -29,9 +29,11 @@ const TIER_WORDS = new Set([ const VERSION = /^v?\d+(?:\.\d+)*$/i; +const KEPT_WHOLE = new Set(["Grok Build"]); + export function modelShortName(name: string, driver: string): string | undefined { const family = MODEL_FAMILIES.find((candidate) => name.startsWith(candidate.prefix)); - if (!family) return undefined; + if (!family || KEPT_WHOLE.has(name)) return undefined; const rest = name.slice(family.prefix.length).trim(); const words = rest .replace(/\(.*?\)/g, " ") @@ -42,7 +44,7 @@ export function modelShortName(name: string, driver: string): string | undefined if (driver !== family.nativeDriver && named.every((word) => TIER_WORDS.has(word.toLowerCase()))) { return undefined; } - return rest; + return rest.replace(/(?<=\S)-(?=\S)/g, " "); } export function withModelShortNames(provider: ServerProvider): ServerProvider { From 808d2213fbb60802d1b20481b27f6f4e48983068 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 21:08:28 +0000 Subject: [PATCH 04/24] fix: strip vendor prefixes and native-only variant words from model short names --- .../src/provider/ProviderRegistry.test.ts | 7 +++- .../src/provider/modelShortName.test.ts | 7 +++- apps/server/src/provider/modelShortName.ts | 34 +++++++++++++++---- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index fc08ca8aeff62..14bf264e1dec6 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -931,7 +931,12 @@ it.layer( (yield* registry.getProviders)[0]?.models, restarted ? presentedRetainedModels - : [defaultModel, customModel, presentedCurrentModel, removedModel], + : [ + defaultModel, + customModel, + presentedCurrentModel, + { ...removedModel, shortName: "Gemini 2.5 Pro" }, + ], ); yield* registry.refreshInstance(instance.instanceId); diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts index 3d36b9df41e59..ce231e7f43847 100644 --- a/apps/server/src/provider/modelShortName.test.ts +++ b/apps/server/src/provider/modelShortName.test.ts @@ -13,9 +13,14 @@ describe("modelShortName", () => { ["GPT-5.4 Mini", "codex", "5.4 Mini"], ["GPT-6.1 Sol", "opencode", "6.1 Sol"], ["GPT-6.1-Sol", "opencode", "6.1 Sol"], - ["Grok Build", "grok", undefined], + ["Grok Build", "grok", "Build"], + ["Grok Build", "cursor", undefined], + ["Gemini 3.1 Pro Preview Custom Tools", "opencode", undefined], + ["NVIDIA: Nemotron 3 Super", "opencode", "Nemotron 3 Super"], + ["Google: Gemini 3.1 Pro", "opencode", "Gemini 3.1 Pro"], ["GPT-5.4 Mini", "pi", undefined], ["GPT-5.5", "codex", undefined], + ["GPT-4o", "opencode", undefined], ["Gemini 3 (High)", "antigravity", undefined], ["Gemini 3.8 Flash (High)", "antigravity", "3.8 Flash (High)"], ["gpt-5.5", "opencode", undefined], diff --git a/apps/server/src/provider/modelShortName.ts b/apps/server/src/provider/modelShortName.ts index 2ca07b6a3fc1d..a4dd9327c58bc 100644 --- a/apps/server/src/provider/modelShortName.ts +++ b/apps/server/src/provider/modelShortName.ts @@ -6,11 +6,12 @@ const MODEL_FAMILIES: ReadonlyArray<{ readonly prefix: string; readonly nativeDr { prefix: "Gemini ", nativeDriver: "antigravity" }, { prefix: "Muse ", nativeDriver: "muse" }, { prefix: "Grok ", nativeDriver: "grok" }, + { prefix: "Cursor ", nativeDriver: "cursor" }, ]; -// Words that rank a model within its family rather than name one. Off the +// Words that describe a variant within a family rather than name a model. Off the // family's native provider there is no icon saying which family "4 Pro" belongs to. -const TIER_WORDS = new Set([ +const VARIANT_WORDS = new Set([ "pro", "flash", "mini", @@ -25,15 +26,33 @@ const TIER_WORDS = new Set([ "latest", "thinking", "reasoning", + "build", + "image", + "vision", + "audio", + "live", + "custom", + "tools", + "exp", + "experimental", + "chat", + "tts", + "stt", ]); -const VERSION = /^v?\d+(?:\.\d+)*$/i; +const VERSION = /^v?\d+(?:\.\d+)*[a-z]?$/i; -const KEPT_WHOLE = new Set(["Grok Build"]); +const VENDOR_PREFIX = /^[A-Z][\w.&\- ]{0,24}: (?=\S)/; export function modelShortName(name: string, driver: string): string | undefined { + const unprefixed = name.replace(VENDOR_PREFIX, ""); + const short = withoutFamily(unprefixed, driver) ?? unprefixed; + return short === name ? undefined : short; +} + +function withoutFamily(name: string, driver: string): string | undefined { const family = MODEL_FAMILIES.find((candidate) => name.startsWith(candidate.prefix)); - if (!family || KEPT_WHOLE.has(name)) return undefined; + if (!family) return undefined; const rest = name.slice(family.prefix.length).trim(); const words = rest .replace(/\(.*?\)/g, " ") @@ -41,7 +60,10 @@ export function modelShortName(name: string, driver: string): string | undefined .filter(Boolean); const named = words.filter((word) => !VERSION.test(word)); if (named.length === 0) return undefined; - if (driver !== family.nativeDriver && named.every((word) => TIER_WORDS.has(word.toLowerCase()))) { + if ( + driver !== family.nativeDriver && + named.every((word) => VARIANT_WORDS.has(word.toLowerCase())) + ) { return undefined; } return rest.replace(/(?<=\S)-(?=\S)/g, " "); From 5284c7a033597cc20141b3a3cf6910cd9e9a0222 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 21:12:49 +0000 Subject: [PATCH 05/24] fix(web): keep the full model name as the picker's accessible label --- apps/web/src/components/chat/ProviderModelPicker.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 1d6fcbb00826e..e7c9dfe19a9ef 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -190,7 +190,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { Date: Fri, 23 Oct 2026 21:12:50 +0000 Subject: [PATCH 06/24] fix: treat Cursor as a native provider for Grok models --- .../server/src/provider/modelShortName.test.ts | 3 ++- apps/server/src/provider/modelShortName.ts | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts index ce231e7f43847..7c94e8b0bb54a 100644 --- a/apps/server/src/provider/modelShortName.test.ts +++ b/apps/server/src/provider/modelShortName.test.ts @@ -14,7 +14,8 @@ describe("modelShortName", () => { ["GPT-6.1 Sol", "opencode", "6.1 Sol"], ["GPT-6.1-Sol", "opencode", "6.1 Sol"], ["Grok Build", "grok", "Build"], - ["Grok Build", "cursor", undefined], + ["Grok Build", "cursor", "Build"], + ["Grok Build", "opencode", undefined], ["Gemini 3.1 Pro Preview Custom Tools", "opencode", undefined], ["NVIDIA: Nemotron 3 Super", "opencode", "Nemotron 3 Super"], ["Google: Gemini 3.1 Pro", "opencode", "Gemini 3.1 Pro"], diff --git a/apps/server/src/provider/modelShortName.ts b/apps/server/src/provider/modelShortName.ts index a4dd9327c58bc..370cc2625d7de 100644 --- a/apps/server/src/provider/modelShortName.ts +++ b/apps/server/src/provider/modelShortName.ts @@ -1,12 +1,14 @@ import type { ServerProvider } from "@t3tools/contracts"; -const MODEL_FAMILIES: ReadonlyArray<{ readonly prefix: string; readonly nativeDriver: string }> = [ - { prefix: "Claude ", nativeDriver: "claudeAgent" }, - { prefix: "GPT-", nativeDriver: "codex" }, - { prefix: "Gemini ", nativeDriver: "antigravity" }, - { prefix: "Muse ", nativeDriver: "muse" }, - { prefix: "Grok ", nativeDriver: "grok" }, - { prefix: "Cursor ", nativeDriver: "cursor" }, +const MODEL_FAMILIES: ReadonlyArray<{ + readonly prefix: string; + readonly nativeDrivers: ReadonlyArray; +}> = [ + { prefix: "Claude ", nativeDrivers: ["claudeAgent"] }, + { prefix: "GPT-", nativeDrivers: ["codex"] }, + { prefix: "Gemini ", nativeDrivers: ["antigravity"] }, + { prefix: "Muse ", nativeDrivers: ["muse"] }, + { prefix: "Grok ", nativeDrivers: ["grok", "cursor"] }, ]; // Words that describe a variant within a family rather than name a model. Off the @@ -61,7 +63,7 @@ function withoutFamily(name: string, driver: string): string | undefined { const named = words.filter((word) => !VERSION.test(word)); if (named.length === 0) return undefined; if ( - driver !== family.nativeDriver && + !family.nativeDrivers.includes(driver) && named.every((word) => VARIANT_WORDS.has(word.toLowerCase())) ) { return undefined; From af9fe8d6e844175165a414e21016bda1b7aa0461 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 21:14:59 +0000 Subject: [PATCH 07/24] fix: keep vendor prefixes when the rest names no model --- .../src/provider/modelShortName.test.ts | 1 + apps/server/src/provider/modelShortName.ts | 26 ++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts index 7c94e8b0bb54a..30df1c851d9f9 100644 --- a/apps/server/src/provider/modelShortName.test.ts +++ b/apps/server/src/provider/modelShortName.test.ts @@ -19,6 +19,7 @@ describe("modelShortName", () => { ["Gemini 3.1 Pro Preview Custom Tools", "opencode", undefined], ["NVIDIA: Nemotron 3 Super", "opencode", "Nemotron 3 Super"], ["Google: Gemini 3.1 Pro", "opencode", "Gemini 3.1 Pro"], + ["Google: 3.1 Pro", "opencode", undefined], ["GPT-5.4 Mini", "pi", undefined], ["GPT-5.5", "codex", undefined], ["GPT-4o", "opencode", undefined], diff --git a/apps/server/src/provider/modelShortName.ts b/apps/server/src/provider/modelShortName.ts index 370cc2625d7de..53652aa7906f7 100644 --- a/apps/server/src/provider/modelShortName.ts +++ b/apps/server/src/provider/modelShortName.ts @@ -48,7 +48,7 @@ const VENDOR_PREFIX = /^[A-Z][\w.&\- ]{0,24}: (?=\S)/; export function modelShortName(name: string, driver: string): string | undefined { const unprefixed = name.replace(VENDOR_PREFIX, ""); - const short = withoutFamily(unprefixed, driver) ?? unprefixed; + const short = withoutFamily(unprefixed, driver) ?? (namesModel(unprefixed) ? unprefixed : name); return short === name ? undefined : short; } @@ -56,21 +56,23 @@ function withoutFamily(name: string, driver: string): string | undefined { const family = MODEL_FAMILIES.find((candidate) => name.startsWith(candidate.prefix)); if (!family) return undefined; const rest = name.slice(family.prefix.length).trim(); - const words = rest - .replace(/\(.*?\)/g, " ") - .split(/[\s-]+/) - .filter(Boolean); - const named = words.filter((word) => !VERSION.test(word)); + const named = nonVersionWords(rest); if (named.length === 0) return undefined; - if ( - !family.nativeDrivers.includes(driver) && - named.every((word) => VARIANT_WORDS.has(word.toLowerCase())) - ) { - return undefined; - } + if (!family.nativeDrivers.includes(driver) && !namesModel(rest)) return undefined; return rest.replace(/(?<=\S)-(?=\S)/g, " "); } +function nonVersionWords(text: string): ReadonlyArray { + return text + .replace(/\(.*?\)/g, " ") + .split(/[\s-]+/) + .filter((word) => word.length > 0 && !VERSION.test(word)); +} + +function namesModel(text: string): boolean { + return nonVersionWords(text).some((word) => !VARIANT_WORDS.has(word.toLowerCase())); +} + export function withModelShortNames(provider: ServerProvider): ServerProvider { let changed = false; const models = provider.models.map((model) => { From ac834b160158c46921f386bc179f668d9fe617f4 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 21:39:02 +0000 Subject: [PATCH 08/24] fix: keep hyphens in spaced model names and treat Non/Transcribe as variant words --- apps/server/src/provider/modelShortName.test.ts | 3 +++ apps/server/src/provider/modelShortName.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/modelShortName.test.ts b/apps/server/src/provider/modelShortName.test.ts index 30df1c851d9f9..27949a09cff7f 100644 --- a/apps/server/src/provider/modelShortName.test.ts +++ b/apps/server/src/provider/modelShortName.test.ts @@ -13,6 +13,9 @@ describe("modelShortName", () => { ["GPT-5.4 Mini", "codex", "5.4 Mini"], ["GPT-6.1 Sol", "opencode", "6.1 Sol"], ["GPT-6.1-Sol", "opencode", "6.1 Sol"], + ["Grok 4.1 Fast Non-Reasoning", "opencode", undefined], + ["Grok 4.1 Fast Non-Reasoning", "grok", "4.1 Fast Non-Reasoning"], + ["GPT-5.3-Codex-Spark", "codex", "5.3 Codex Spark"], ["Grok Build", "grok", "Build"], ["Grok Build", "cursor", "Build"], ["Grok Build", "opencode", undefined], diff --git a/apps/server/src/provider/modelShortName.ts b/apps/server/src/provider/modelShortName.ts index 53652aa7906f7..f94a7cd67c9c7 100644 --- a/apps/server/src/provider/modelShortName.ts +++ b/apps/server/src/provider/modelShortName.ts @@ -40,6 +40,8 @@ const VARIANT_WORDS = new Set([ "chat", "tts", "stt", + "non", + "transcribe", ]); const VERSION = /^v?\d+(?:\.\d+)*[a-z]?$/i; @@ -59,7 +61,7 @@ function withoutFamily(name: string, driver: string): string | undefined { const named = nonVersionWords(rest); if (named.length === 0) return undefined; if (!family.nativeDrivers.includes(driver) && !namesModel(rest)) return undefined; - return rest.replace(/(?<=\S)-(?=\S)/g, " "); + return /\s/.test(rest) ? rest : rest.replaceAll("-", " "); } function nonVersionWords(text: string): ReadonlyArray { From f0c09d5fc740b493c0275fe5026add53d9572a75 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 21:54:21 +0000 Subject: [PATCH 09/24] feat: show the spoken model name on the composer model button --- .../src/components/ComposerModelControl.tsx | 7 +- .../components/chat/ProviderModelPicker.tsx | 7 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/modelLabel.test.ts | 36 +++++++++ packages/client-runtime/src/modelLabel.ts | 73 +++++++++++++++++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 packages/client-runtime/src/modelLabel.test.ts create mode 100644 packages/client-runtime/src/modelLabel.ts diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index e422cfabd4591..add439abb49e8 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -3,6 +3,7 @@ import { buildTraitsTriggerDisplay, getPromptControlledTraits, } from "@t3tools/client-runtime/model-traits"; +import { composerModelLabel } from "@t3tools/client-runtime/model-label"; import type { ModelOption } from "../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../lib/providerOptions"; import { AppText as Text } from "./ui/AppText"; @@ -70,7 +71,11 @@ export function ComposerModelControl(props: { /> ) } - label={props.modelOption?.shortName ?? label} + label={ + props.modelOption + ? composerModelLabel(props.modelOption.shortName ?? props.modelOption.label) + : label + } iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index e7c9dfe19a9ef..4557a154f812e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,6 +5,7 @@ import { type ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { composerModelLabel } from "@t3tools/client-runtime/model-label"; import { memo, useEffect, useState } from "react"; import { Badge } from "../ui/badge"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -70,7 +71,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel - ? getTriggerDisplayModelName(selectedModel) + ? composerModelLabel(getTriggerDisplayModelName(selectedModel)) : props.model === ANTIGRAVITY_DEFAULT_MODEL ? "Choose model" : props.model || "Choose model"; @@ -157,7 +158,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { return { ...selection, entry, - label: model ? `${getTriggerDisplayModelName(model)}${unavailable}` : selection.model, + label: model + ? `${composerModelLabel(getTriggerDisplayModelName(model))}${unavailable}` + : selection.model, fullLabel: model ? `${getDisplayModelName(model)}${unavailable}` : selection.model, }; }); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 03779b627eea0..1cbf4ab0f23d1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -478,6 +478,10 @@ "./model-traits": { "types": "./src/modelTraits.ts", "default": "./src/modelTraits.ts" + }, + "./model-label": { + "types": "./src/modelLabel.ts", + "default": "./src/modelLabel.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/modelLabel.test.ts b/packages/client-runtime/src/modelLabel.test.ts new file mode 100644 index 0000000000000..07dd7219086da --- /dev/null +++ b/packages/client-runtime/src/modelLabel.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { composerModelLabel } from "./modelLabel.ts"; + +describe("composerModelLabel", () => { + it.each([ + ["Nemotron 3.5 Super VL Free", "Nemotron 3.5 Super"], + ["Ox Alpha Free (Unlimited)", "Ox Alpha"], + ["DeepSeek V4 Flash Vision Exp", "DeepSeek V4 Flash"], + ["LongCat 2.5 Preview Free", "LongCat 2.5"], + ["4 Argon Contributor", "4 Argon Contributor"], + ["Nemotron 3 Nano Omni (free)", "Nemotron 3 Nano Omni"], + ["Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)", "Nano Banana 2 Lite"], + ["GPT-OSS (Vertex AI (OpenAI-compatible))", "GPT-OSS"], + ["NVIDIA: Nemotron 3.5 Lightning Free", "Nemotron 3.5 Lightning"], + ["3.1 Pro Preview Custom Tools", "3.1 Pro"], + ["Qwen3 235B-A22B Thinking 2507", "Qwen3 235B"], + ["Qwen3-Next 80B-A3B (Thinking)", "Qwen3-Next 80B"], + ["Nemotron 3 Nano Omni 30B A3B Reasoning", "Nemotron 3 Nano Omni"], + ["Qwen3 Coder 480B A35B Instruct Turbo", "Qwen3 Coder"], + ["Llama 4 Maverick 17B Instruct (US)", "Llama 4 Maverick"], + ["DeepSeek V4.1 Flash 1017 Spot", "DeepSeek V4.1 Flash"], + ["GLM Flash Latest (GLM 5.3 Flash)", "GLM Flash"], + ["Grok 4.20 (Non-Reasoning)", "Grok 4.20"], + ["4.1 Fast Non-Reasoning", "4.1 Fast"], + ["3.1 Flash Image Preview (Nano Banana 2)", "3.1 Flash Image"], + ["Gemini 2.5 Flash Preview (09/2025) – Thinking", "Gemini 2.5 Flash"], + ["Opus 5.5", "Opus 5.5"], + ["GPT-5.5", "GPT-5.5"], + ["Grok 4", "Grok 4"], + ["Thinking", "Thinking"], + ["(Preview)", "(Preview)"], + ])("labels %s as %s", (name, label) => { + expect(composerModelLabel(name)).toBe(label); + }); +}); diff --git a/packages/client-runtime/src/modelLabel.ts b/packages/client-runtime/src/modelLabel.ts new file mode 100644 index 0000000000000..143c609867eec --- /dev/null +++ b/packages/client-runtime/src/modelLabel.ts @@ -0,0 +1,73 @@ +const VENDOR_PREFIX = /^[^:()]+:\s+(?=\S)/; +const PARENTHETICAL = /\s*\([^()]*\)/g; + +const VARIANT_PHRASES: ReadonlyArray> = [ + ["custom", "tools"], + ...[ + "free", + "preview", + "exp", + "experimental", + "instruct", + "chat", + "base", + "turbo", + "spot", + "latest", + "new", + "thinking", + "reasoning", + "non-reasoning", + "distilled", + "fp8", + "fp16", + "bf16", + "int4", + "int8", + "vl", + "vision", + ].map((word) => [word]), +]; + +const SEPARATOR = /^[^\p{L}\p{N}]+$/u; +const STAMP = /^\d{4,}(?:-\d{2}){0,2}$/; +const SIZE_PART = /^(?:\d+x)?a?\d+(?:\.\d+)?[bkmte]$/i; +const VERSION = /^v?\d+(?:\.\d+)*[a-z]?$/i; + +const isSize = (token: string) => token.split("-").every((part) => SIZE_PART.test(part)); + +function trailingVariantLength(tokens: ReadonlyArray): number { + const last = tokens.at(-1)!; + if (STAMP.test(last) || SEPARATOR.test(last)) return 1; + const phrase = VARIANT_PHRASES.find( + (words) => + words.length < tokens.length && + words.every( + (word, index) => tokens[tokens.length - words.length + index]!.toLowerCase() === word, + ), + ); + return phrase?.length ?? 0; +} + +export function composerModelLabel(displayName: string): string { + let name = displayName.trim().replace(VENDOR_PREFIX, ""); + for (let previous = ""; previous !== name;) { + previous = name; + name = name.replace(PARENTHETICAL, ""); + } + const tokens = name.split(/\s+/).filter(Boolean); + let size: string | undefined; + while (tokens.length > 1) { + const variantLength = trailingVariantLength(tokens); + if (variantLength > 0) { + tokens.splice(-variantLength); + } else if (isSize(tokens.at(-1)!)) { + size = tokens.pop()!.split("-")[0]; + } else { + break; + } + } + const distinguished = tokens.slice(1).some((token) => !VERSION.test(token) && !isSize(token)); + if (size && !distinguished) tokens.push(size); + return tokens.join(" ") || displayName; +} From 17ece6a49f5dac900050105c81461449a2273595 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 23 Oct 2026 22:33:54 +0000 Subject: [PATCH 10/24] fix(mobile): drop the composer trait suffix before truncating the model name Measure the model control's natural width (with suffix) in a hidden copy and the room from a flexible slot; show the suffix only when the whole control fits. OpenCode's Agent option no longer appears in the suffix. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ComposerModelControl.tsx | 155 ++++++++++++------ .../src/features/threads/ThreadComposer.tsx | 24 ++- 2 files changed, 112 insertions(+), 67 deletions(-) diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index add439abb49e8..8cf18fad0cc6f 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -1,4 +1,6 @@ import type { ModelSelection, ProviderDriverKind } from "@t3tools/contracts"; +import { useState } from "react"; +import { View } from "react-native"; import { buildTraitsTriggerDisplay, getPromptControlledTraits, @@ -10,6 +12,9 @@ import { AppText as Text } from "./ui/AppText"; import { ComposerInlineControl } from "./ComposerToolbar"; import { ProviderIcon, ProviderInstanceIcon } from "./ProviderIcon"; +const SUFFIX_EXCLUDED_DESCRIPTOR_IDS: ReadonlySet = new Set(["agent", "_t3/session-mode"]); +const NATURAL_WIDTH_PROBE_WIDTH = 2000; + export function ComposerModelControl(props: { readonly modelOption: ModelOption | null; readonly selection: ModelSelection | null; @@ -30,7 +35,9 @@ export function ComposerModelControl(props: { const summary = props.provider ? buildTraitsTriggerDisplay({ provider: props.provider, - descriptors, + descriptors: descriptors.filter( + (descriptor) => !SUFFIX_EXCLUDED_DESCRIPTOR_IDS.has(descriptor.id), + ), primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, modelSelection: props.selection, @@ -39,60 +46,100 @@ export function ComposerModelControl(props: { : ""; const badge = props.modelOption?.providerBadge; const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; - return ( - - badge?.showBadge ? ( - - ) : ( - - ) - } - label={ - props.modelOption - ? composerModelLabel(props.modelOption.shortName ?? props.modelOption.label) - : label + const displayLabel = props.modelOption + ? composerModelLabel(props.modelOption.shortName ?? props.modelOption.label) + : label; + const suffix = summary ? ( + + {summary} + + ) : undefined; + const [roomWidth, setRoomWidth] = useState(0); + const [naturalWidth, setNaturalWidth] = useState<{ + readonly key: string; + readonly width: number; + } | null>(null); + const naturalWidthKey = `${props.size ?? "regular"}\u0000${displayLabel}\u0000${summary}`; + const measuresSuffix = suffix !== undefined && !props.iconOnly; + const showsSuffix = + measuresSuffix && naturalWidth?.key === naturalWidthKey && naturalWidth.width <= roomWidth; + return ( + setRoomWidth(event.nativeEvent.layout.width)} + > + {measuresSuffix ? ( + + { + const width = event.nativeEvent.layout.width; + setNaturalWidth((current) => + current?.key === naturalWidthKey && current.width === width + ? current + : { key: naturalWidthKey, width }, + ); + }} > - {summary} - - ) : undefined - } - maxWidth="100%" - onPress={props.onPress} - /> + null} + label={displayLabel} + suffix={suffix} + maxWidth="100%" + /> + + + ) : null} + + badge?.showBadge ? ( + + ) : ( + + ) + } + label={displayLabel} + iconOnly={props.iconOnly} + suffix={showsSuffix ? suffix : undefined} + maxWidth="100%" + onPress={props.onPress} + /> + ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 2f913144332d6..9b61cb264cafe 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -844,19 +844,17 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ) : null; const modelControl = ( - - - + ); return ( From 21d46ae27a1716c5ec6bec626c1cf37a5437ea12 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 01:53:01 +0000 Subject: [PATCH 11/24] fix(mobile): re-measure the composer model control when its text changes --- apps/mobile/src/components/ComposerModelControl.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 8cf18fad0cc6f..1b5420c6569eb 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -82,6 +82,7 @@ export function ComposerModelControl(props: { style={{ width: NATURAL_WIDTH_PROBE_WIDTH }} > { const width = event.nativeEvent.layout.width; setNaturalWidth((current) => From 461d437464f31da75475767b86a46265f71d5d5d Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 24 Oct 2026 01:57:08 +0000 Subject: [PATCH 12/24] fix(mobile): keep agent and session mode in the model control's accessibility label --- .../src/components/ComposerModelControl.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 1b5420c6569eb..bc9e7f8aaffe1 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -32,18 +32,21 @@ export function ComposerModelControl(props: { selections: props.selection?.options, }); const promptTraits = getPromptControlledTraits(descriptors, props.prompt); - const summary = props.provider - ? buildTraitsTriggerDisplay({ - provider: props.provider, - descriptors: descriptors.filter( - (descriptor) => !SUFFIX_EXCLUDED_DESCRIPTOR_IDS.has(descriptor.id), - ), - primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, - ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, - modelSelection: props.selection, - reportedModelSelection: props.reportedSelection, - }).label - : ""; + const traitsSummary = (visibleDescriptors: typeof descriptors) => + props.provider + ? buildTraitsTriggerDisplay({ + provider: props.provider, + descriptors: visibleDescriptors, + primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, + ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, + modelSelection: props.selection, + reportedModelSelection: props.reportedSelection, + }).label + : ""; + const summary = traitsSummary( + descriptors.filter((descriptor) => !SUFFIX_EXCLUDED_DESCRIPTOR_IDS.has(descriptor.id)), + ); + const fullSummary = traitsSummary(descriptors); const badge = props.modelOption?.providerBadge; const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; const displayLabel = props.modelOption @@ -109,7 +112,7 @@ export function ComposerModelControl(props: { "Model and reasoning settings", label, badge?.showBadge ? badge.displayName : null, - summary, + fullSummary, ] .filter(Boolean) .join(", ")} From 6eac9802dda382aa496ea8bb4981c1073cf7bc37 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 20:52:33 +0000 Subject: [PATCH 13/24] fix: keep compact model labels unambiguous --- .../src/components/ComposerModelControl.tsx | 174 ++++++---------- .../src/features/threads/ThreadComposer.tsx | 24 ++- apps/web/src/appInit.tsx | 2 + .../components/ChatMarkdown.browser.test.tsx | 32 +++ apps/web/src/components/ChatMarkdown.tsx | 12 +- .../components/ComposerPromptEditorTiptap.tsx | 28 +-- .../src/components/chat/MessagesTimeline.tsx | 4 +- .../components/chat/ProviderModelPicker.tsx | 7 +- .../src/components/chat/SkillInlineText.tsx | 74 +++++-- .../chat/TimelineUserMessageRow.tsx | 4 +- .../chat/UserMessageContextChips.tsx | 39 ++-- .../src/components/chat/timelineRowContext.ts | 2 +- apps/web/src/components/contextChipParts.tsx | 2 +- apps/web/src/hooks/useHandleNewThread.test.ts | 2 +- apps/web/src/hooks/useHandleNewThread.ts | 43 ++-- apps/web/src/hooks/useScratchProject.ts | 55 +++-- apps/web/src/routes/_chat.tsx | 191 +----------------- .../chatRouteCommands.browser.test.ts | 67 ++++++ apps/web/src/shortcuts/chatRouteCommands.ts | 179 ++++++++++++++++ packages/client-runtime/package.json | 4 - .../client-runtime/src/modelLabel.test.ts | 36 ---- packages/client-runtime/src/modelLabel.ts | 73 ------- packages/shared/src/inlineSkills.ts | 5 +- packages/shared/src/relayClient.ts | 26 +-- 24 files changed, 538 insertions(+), 547 deletions(-) create mode 100644 apps/web/src/shortcuts/chatRouteCommands.browser.test.ts create mode 100644 apps/web/src/shortcuts/chatRouteCommands.ts delete mode 100644 packages/client-runtime/src/modelLabel.test.ts delete mode 100644 packages/client-runtime/src/modelLabel.ts diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index bc9e7f8aaffe1..e422cfabd4591 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -1,20 +1,14 @@ import type { ModelSelection, ProviderDriverKind } from "@t3tools/contracts"; -import { useState } from "react"; -import { View } from "react-native"; import { buildTraitsTriggerDisplay, getPromptControlledTraits, } from "@t3tools/client-runtime/model-traits"; -import { composerModelLabel } from "@t3tools/client-runtime/model-label"; import type { ModelOption } from "../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../lib/providerOptions"; import { AppText as Text } from "./ui/AppText"; import { ComposerInlineControl } from "./ComposerToolbar"; import { ProviderIcon, ProviderInstanceIcon } from "./ProviderIcon"; -const SUFFIX_EXCLUDED_DESCRIPTOR_IDS: ReadonlySet = new Set(["agent", "_t3/session-mode"]); -const NATURAL_WIDTH_PROBE_WIDTH = 2000; - export function ComposerModelControl(props: { readonly modelOption: ModelOption | null; readonly selection: ModelSelection | null; @@ -32,118 +26,68 @@ export function ComposerModelControl(props: { selections: props.selection?.options, }); const promptTraits = getPromptControlledTraits(descriptors, props.prompt); - const traitsSummary = (visibleDescriptors: typeof descriptors) => - props.provider - ? buildTraitsTriggerDisplay({ - provider: props.provider, - descriptors: visibleDescriptors, - primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, - ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, - modelSelection: props.selection, - reportedModelSelection: props.reportedSelection, - }).label - : ""; - const summary = traitsSummary( - descriptors.filter((descriptor) => !SUFFIX_EXCLUDED_DESCRIPTOR_IDS.has(descriptor.id)), - ); - const fullSummary = traitsSummary(descriptors); + const summary = props.provider + ? buildTraitsTriggerDisplay({ + provider: props.provider, + descriptors, + primarySelectDescriptorId: promptTraits.primarySelectDescriptor?.id ?? null, + ultrathinkPromptControlled: promptTraits.ultrathinkPromptControlled, + modelSelection: props.selection, + reportedModelSelection: props.reportedSelection, + }).label + : ""; const badge = props.modelOption?.providerBadge; const label = props.modelOption?.label ?? props.selection?.model ?? "Choose model"; - const displayLabel = props.modelOption - ? composerModelLabel(props.modelOption.shortName ?? props.modelOption.label) - : label; - const suffix = summary ? ( - - {summary} - - ) : undefined; - const [roomWidth, setRoomWidth] = useState(0); - const [naturalWidth, setNaturalWidth] = useState<{ - readonly key: string; - readonly width: number; - } | null>(null); - const naturalWidthKey = `${props.size ?? "regular"}\u0000${displayLabel}\u0000${summary}`; - const measuresSuffix = suffix !== undefined && !props.iconOnly; - const showsSuffix = - measuresSuffix && naturalWidth?.key === naturalWidthKey && naturalWidth.width <= roomWidth; return ( - setRoomWidth(event.nativeEvent.layout.width)} - > - {measuresSuffix ? ( - - { - const width = event.nativeEvent.layout.width; - setNaturalWidth((current) => - current?.key === naturalWidthKey && current.width === width - ? current - : { key: naturalWidthKey, width }, - ); - }} + + badge?.showBadge ? ( + + ) : ( + + ) + } + label={props.modelOption?.shortName ?? label} + iconOnly={props.iconOnly} + suffix={ + summary ? ( + - null} - label={displayLabel} - suffix={suffix} - maxWidth="100%" - /> - - - ) : null} - - badge?.showBadge ? ( - - ) : ( - - ) - } - label={displayLabel} - iconOnly={props.iconOnly} - suffix={showsSuffix ? suffix : undefined} - maxWidth="100%" - onPress={props.onPress} - /> - + {summary} + + ) : undefined + } + maxWidth="100%" + onPress={props.onPress} + /> ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index a90b0074a32a9..5f910dfc929f5 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -766,17 +766,19 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ) : null; const modelControl = ( - + + + ); return ( diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index b8fab6ce9ff9f..a8ecc16df3d6c 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -42,6 +42,7 @@ import { startProviderAuthDelivery } from "./providerAuthDelivery"; import { getRouter, type AppRouter } from "./router"; import { appAtomRegistry } from "./rpc/atomRegistry"; import { primaryServerConfigAtom } from "./state/server"; +import { installChatRouteCommands } from "./shortcuts/chatRouteCommands"; import { resolveThreadRouteTarget } from "./threadRoutes"; /** Writes appearance settings onto the document, touching only the groups that changed. */ @@ -168,6 +169,7 @@ export function startAppServices(): AppRouter { if (isElectron) startElectronBrowserHost(); startPreviewTitleRecording(); startBrowserProfileReporting(); + installChatRouteCommands(router); startThreadOverlayRetention(router); startMainAppLocationTracking(router); startBackgroundThreadAnnouncements(appAtomRegistry, router); diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 558fe2d7ea92b..d67803a6399ce 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -2,6 +2,7 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; import { type ReactElement } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; import "../index.css"; @@ -260,6 +261,37 @@ describe("ChatMarkdown workspace file opens", () => { }); }); +describe("ChatMarkdown skill chips", () => { + it("shows the skill's details and opens its instructions", async () => { + setup(); + const threadRef = { + environmentId: EnvironmentId.make("workspace"), + threadId: ThreadId.make("thread"), + }; + await using screen = await renderText("Run $review now", { + threadRef, + skills: [ + { + name: "review", + displayName: "Review", + description: "Reviews the current branch.", + path: "/skills/review/SKILL.md", + enabled: true, + }, + ], + }); + using openFile = vi + .spyOn(useRightPanelStore.getState(), "openFile") + .mockImplementation(() => {}); + + await userEvent.click(screen.getByRole("button", { name: "Skill Review. Show details" })); + await expect.element(page.getByText("Reviews the current branch.")).toBeVisible(); + await userEvent.click(page.getByRole("button", { name: "View instructions" })); + + expect(openFile).toHaveBeenCalledExactlyOnceWith(threadRef, "/skills/review/SKILL.md"); + }); +}); + describe("ChatMarkdown headings", () => { it("never gives two headings the same id, even when a suffix matches another heading", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e5691c8cbd35e..8831ec98e224c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -227,7 +227,7 @@ interface ChatMarkdownProps { environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; - skills?: ReadonlyArray>; + skills?: ReadonlyArray; className?: string; lineBreaks?: boolean; parseRawHtml?: boolean; @@ -250,7 +250,7 @@ export interface ChatMarkdownContextReference { label: string; } -const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_MARKDOWN_SKILLS: ReadonlyArray = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { @@ -958,8 +958,8 @@ const CHAT_MARKDOWN_COMPONENTS = { return
{children}
; }, p: function MarkdownParagraph({ node: _node, children, ...props }) { - const { skills } = use(ChatMarkdownRendererContext); - return

{renderSkillInlineMarkdownChildren(children, skills)}

; + const { skills, threadRef } = use(ChatMarkdownRendererContext); + return

{renderSkillInlineMarkdownChildren(children, skills, threadRef)}

; }, blockquote: function MarkdownBlockquote({ node: _node, children, ...props }) { const alert = @@ -994,12 +994,12 @@ const CHAT_MARKDOWN_COMPONENTS = { ); }, li: function MarkdownListItem({ node, children, ...props }) { - const { skills } = use(ChatMarkdownRendererContext); + const { skills, threadRef } = use(ChatMarkdownRendererContext); // Resolve checkbox markers by offset to avoid rerendering list items on every streamed token. const isTaskItem = String(props.className ?? "").includes("task-list-item"); return (
  • - {renderSkillInlineMarkdownChildren(children, skills)} + {renderSkillInlineMarkdownChildren(children, skills, threadRef)}
  • ); }, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4d20d5b5cb106..f0a23d2ab4336 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -108,11 +108,9 @@ import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; import { FileTagChipContent } from "./chat/FileTagChip"; -import { SkillChipIcon } from "./chat/SkillInlineText"; +import { SkillChipPopover, skillPopoverDescription } from "./chat/SkillInlineText"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { getTimelinePageScrollKey } from "./chat/pageScrollController"; -import { ContextChipPopover } from "./contextChipParts"; -import { Button } from "./ui/button"; import { ContextChip } from "./ContextChip"; import { ComposerContextActionsContext, @@ -348,31 +346,19 @@ function ComposerSkillNodeView({ node }: NodeViewProps) { const skill = skills.find( (candidate) => candidate.name === skillName && isProviderSkillUserInvocable(candidate), ); - const description = skill - ? skill.description?.trim() || skill.shortDescription?.trim() - : skillDescription; // The node outlives a provider switch, so its stored label can be another provider's. const skillLabel = skill ? formatProviderSkillDisplayName(skill) : (node.attrs.skillLabel as string) || skillName; return ( - } + -
    -

    {skillLabel}

    -

    {description || "No description is available for this skill."}

    - {skill?.path && actions.environmentId ? ( - - ) : null} -
    -
    + description={skill ? skillPopoverDescription(skill) : skillDescription} + onViewInstructions={ + skill?.path && actions.environmentId ? () => actions.openMention(skill.path) : undefined + } + />
    ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6458bd12e589c..a8a81d66dd832 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -157,7 +157,7 @@ function TimelineListFooter({ ); } -const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const EMPTY_TIMELINE_SKILLS: ReadonlyArray = []; const TIMELINE_MAINTAIN_SCROLL_AT_END = { animated: false, on: { @@ -253,7 +253,7 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; - skills?: ReadonlyArray>; + skills?: ReadonlyArray; runs: ReadonlyArray; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 6824b1a4d73b2..267fcfe1c5aa6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -5,7 +5,6 @@ import { type ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; -import { composerModelLabel } from "@t3tools/client-runtime/model-label"; import { memo, useRef, useState } from "react"; import { Badge } from "../ui/badge"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -118,7 +117,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ? undefined : selectedInstanceOptions[0]); const triggerTitle = selectedModel - ? composerModelLabel(getTriggerDisplayModelName(selectedModel)) + ? getTriggerDisplayModelName(selectedModel) : props.model === ANTIGRAVITY_DEFAULT_MODEL ? "Choose model" : props.model || "Choose model"; @@ -157,9 +156,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { return { ...selection, entry, - label: model - ? `${composerModelLabel(getTriggerDisplayModelName(model))}${unavailable}` - : selection.model, + label: model ? `${getTriggerDisplayModelName(model)}${unavailable}` : selection.model, fullLabel: model ? `${getDisplayModelName(model)}${unavailable}` : selection.model, }; }); diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 0190298d29444..59fb9d141e581 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -1,11 +1,18 @@ +import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { Children, cloneElement, isValidElement, type ReactNode } from "react"; -import { matchInlineSkills, type InlineSkill } from "@t3tools/shared/inlineSkills"; +import { matchInlineSkills } from "@t3tools/shared/inlineSkills"; +import { useRightPanelStore } from "~/rightPanelStore"; import { SKILL_CHIP_ICON_SVG } from "../composerInlineChip"; -import { ContextChip, ContextChipLabel } from "../ContextChip"; +import { ContextChipPopover } from "../contextChipParts"; +import { Button } from "../ui/button"; -function SkillInlineText(props: { text: string; skills: ReadonlyArray }) { +function SkillInlineText(props: { + text: string; + skills: ReadonlyArray; + threadRef: ScopedThreadRef | undefined; +}) { const nodes: ReactNode[] = []; let cursor = 0; @@ -13,7 +20,15 @@ function SkillInlineText(props: { text: string; skills: ReadonlyArray cursor) { nodes.push(props.text.slice(cursor, start)); } - nodes.push(); + nodes.push( + , + ); cursor = end; } @@ -28,11 +43,12 @@ function SkillInlineText(props: { text: string; skills: ReadonlyArray, + skills: ReadonlyArray, + threadRef: ScopedThreadRef | undefined, ): ReactNode { return Children.map(children, (child) => { if (typeof child === "string") { - return ; + return ; } if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) { return child; @@ -48,21 +64,53 @@ export function renderSkillInlineMarkdownChildren( return cloneElement( child, undefined, - renderSkillInlineMarkdownChildren(child.props.children, skills), + renderSkillInlineMarkdownChildren(child.props.children, skills, threadRef), ); }); } -function SkillChip(props: { skill: InlineSkill; rawText: string }) { +export function skillPopoverDescription( + skill: Pick, +) { + return skill.description?.trim() || skill.shortDescription?.trim() || null; +} + +export function skillInstructionsOpener( + threadRef: ScopedThreadRef | null | undefined, + skill: Pick | undefined, +) { + if (!threadRef || !skill?.path) return undefined; + return () => useRightPanelStore.getState().openFile(threadRef, skill.path); +} + +export function SkillChipPopover(props: { + label: string; + description: string | null | undefined; + copyMarkdown?: string | undefined; + onViewInstructions: (() => void) | undefined; +}) { return ( - - - {formatProviderSkillDisplayName(props.skill)} - + } + label={props.label} + accessibleLabel={`Skill ${props.label}`} + copyMarkdown={props.copyMarkdown} + > +
    +

    {props.label}

    +

    {props.description || "No description is available for this skill."}

    + {props.onViewInstructions ? ( + + ) : null} +
    +
    ); } -export function SkillChipIcon() { +function SkillChipIcon() { return (